2023-05-28 17:47:28 +02:00
|
|
|
const Path = require('path')
|
2022-12-04 23:23:15 +01:00
|
|
|
const fs = require('../libs/fsExtra')
|
2022-03-11 01:45:02 +01:00
|
|
|
const Logger = require('../Logger')
|
2022-11-24 22:53:58 +01:00
|
|
|
const SocketAuthority = require('../SocketAuthority')
|
|
|
|
|
2023-04-10 00:05:35 +02:00
|
|
|
const zipHelpers = require('../utils/zipHelpers')
|
2022-10-02 22:24:32 +02:00
|
|
|
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
|
2022-03-18 17:51:55 +01:00
|
|
|
const { ScanResult } = require('../utils/constants')
|
2023-05-28 19:34:22 +02:00
|
|
|
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
2022-03-11 01:45:02 +01:00
|
|
|
|
|
|
|
class LibraryItemController {
|
|
|
|
constructor() { }
|
|
|
|
|
2022-03-21 11:08:33 +01:00
|
|
|
// Example expand with authors: api/items/:id?expanded=1&include=authors
|
2022-03-11 01:45:02 +01:00
|
|
|
findOne(req, res) {
|
2022-03-21 11:08:33 +01:00
|
|
|
const includeEntities = (req.query.include || '').split(',')
|
|
|
|
if (req.query.expanded == 1) {
|
|
|
|
var item = req.libraryItem.toJSONExpanded()
|
|
|
|
|
2022-04-26 02:03:26 +02:00
|
|
|
// Include users media progress
|
|
|
|
if (includeEntities.includes('progress')) {
|
|
|
|
var episodeId = req.query.episode || null
|
|
|
|
item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
|
|
|
|
}
|
|
|
|
|
2022-05-02 23:42:30 +02:00
|
|
|
if (includeEntities.includes('rssfeed')) {
|
2022-12-28 01:03:31 +01:00
|
|
|
const feedData = this.rssFeedManager.findFeedForEntityId(item.id)
|
2022-12-26 23:58:36 +01:00
|
|
|
item.rssFeed = feedData ? feedData.toJSONMinified() : null
|
2022-05-02 23:42:30 +02:00
|
|
|
}
|
|
|
|
|
2022-03-21 11:08:33 +01:00
|
|
|
if (item.mediaType == 'book') {
|
|
|
|
if (includeEntities.includes('authors')) {
|
|
|
|
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
|
|
|
var author = this.db.authors.find(_au => _au.id === au.id)
|
|
|
|
if (!author) return null
|
|
|
|
return {
|
|
|
|
...author
|
|
|
|
}
|
|
|
|
}).filter(au => au)
|
|
|
|
}
|
2022-04-24 02:41:06 +02:00
|
|
|
} else if (includeEntities.includes('downloads')) {
|
2023-03-05 17:35:34 +01:00
|
|
|
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
|
|
|
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
|
|
|
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
|
|
|
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
|
|
|
|
}
|
2022-03-21 11:08:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return res.json(item)
|
|
|
|
}
|
2022-03-11 01:45:02 +01:00
|
|
|
res.json(req.libraryItem)
|
|
|
|
}
|
|
|
|
|
2022-03-12 02:46:32 +01:00
|
|
|
async update(req, res) {
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
// Item has cover and update is removing cover so purge it from cache
|
|
|
|
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
|
|
|
|
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
|
|
|
}
|
|
|
|
|
2022-12-29 01:08:03 +01:00
|
|
|
const hasUpdates = libraryItem.update(req.body)
|
2022-03-12 02:46:32 +01:00
|
|
|
if (hasUpdates) {
|
|
|
|
Logger.debug(`[LibraryItemController] Updated now saving`)
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
2022-03-12 02:46:32 +01:00
|
|
|
}
|
|
|
|
res.json(libraryItem.toJSON())
|
|
|
|
}
|
|
|
|
|
2022-03-13 00:45:32 +01:00
|
|
|
async delete(req, res) {
|
2023-04-14 23:44:41 +02:00
|
|
|
const hardDelete = req.query.hard == 1 // Delete from file system
|
|
|
|
const libraryItemPath = req.libraryItem.path
|
2022-03-13 00:45:32 +01:00
|
|
|
await this.handleDeleteLibraryItem(req.libraryItem)
|
2023-04-14 23:44:41 +02:00
|
|
|
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)
|
|
|
|
})
|
|
|
|
}
|
2022-03-13 00:45:32 +01:00
|
|
|
res.sendStatus(200)
|
|
|
|
}
|
|
|
|
|
2023-04-10 00:05:35 +02:00
|
|
|
download(req, res) {
|
|
|
|
if (!req.user.canDownload) {
|
|
|
|
Logger.warn('User attempted to download without permission', req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
const libraryItemPath = req.libraryItem.path
|
|
|
|
const filename = `${req.libraryItem.media.metadata.title}.zip`
|
|
|
|
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
|
|
|
|
}
|
|
|
|
|
2022-03-12 02:46:32 +01:00
|
|
|
//
|
|
|
|
// PATCH: will create new authors & series if in payload
|
|
|
|
//
|
|
|
|
async updateMedia(req, res) {
|
2022-12-29 01:08:03 +01:00
|
|
|
const libraryItem = req.libraryItem
|
|
|
|
const mediaPayload = req.body
|
2022-03-12 02:46:32 +01:00
|
|
|
// Item has cover and update is removing cover so purge it from cache
|
|
|
|
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
|
|
|
|
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
|
|
|
}
|
|
|
|
|
2022-08-20 01:41:58 +02:00
|
|
|
// Book specific
|
2022-04-14 01:13:39 +02:00
|
|
|
if (libraryItem.isBook) {
|
|
|
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
|
|
|
}
|
2022-03-12 02:46:32 +01:00
|
|
|
|
2022-08-20 01:41:58 +02:00
|
|
|
// Podcast specific
|
2022-12-29 01:08:03 +01:00
|
|
|
let isPodcastAutoDownloadUpdated = false
|
2022-08-20 01:41:58 +02:00
|
|
|
if (libraryItem.isPodcast) {
|
|
|
|
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
|
|
|
|
isPodcastAutoDownloadUpdated = true
|
|
|
|
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
|
|
|
isPodcastAutoDownloadUpdated = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-31 23:58:19 +01:00
|
|
|
// Book specific - Get all series being removed from this item
|
|
|
|
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))
|
|
|
|
}
|
|
|
|
|
2022-12-29 01:08:03 +01:00
|
|
|
const hasUpdates = libraryItem.media.update(mediaPayload)
|
2022-03-12 02:46:32 +01:00
|
|
|
if (hasUpdates) {
|
2022-12-29 01:08:03 +01:00
|
|
|
libraryItem.updatedAt = Date.now()
|
|
|
|
|
2022-12-31 23:58:19 +01:00
|
|
|
if (seriesRemoved.length) {
|
|
|
|
// Check remove empty series
|
|
|
|
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
|
|
|
await this.checkRemoveEmptySeries(seriesRemoved)
|
|
|
|
}
|
|
|
|
|
2022-08-20 01:41:58 +02:00
|
|
|
if (isPodcastAutoDownloadUpdated) {
|
|
|
|
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
|
|
|
}
|
|
|
|
|
2022-03-12 02:46:32 +01:00
|
|
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
2022-03-12 02:46:32 +01:00
|
|
|
}
|
2022-03-14 14:12:28 +01:00
|
|
|
res.json({
|
|
|
|
updated: hasUpdates,
|
|
|
|
libraryItem
|
|
|
|
})
|
2022-03-12 02:46:32 +01:00
|
|
|
}
|
|
|
|
|
2022-03-13 00:45:32 +01:00
|
|
|
// POST: api/items/:id/cover
|
|
|
|
async uploadCover(req, res) {
|
2022-03-14 14:12:28 +01:00
|
|
|
if (!req.user.canUpload) {
|
2022-03-13 00:45:32 +01:00
|
|
|
Logger.warn('User attempted to upload a cover without permission', req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
|
|
|
|
var result = null
|
|
|
|
if (req.body && req.body.url) {
|
|
|
|
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
|
2022-03-20 22:41:06 +01:00
|
|
|
result = await this.coverManager.downloadCoverFromUrl(libraryItem, req.body.url)
|
2022-03-13 00:45:32 +01:00
|
|
|
} else if (req.files && req.files.cover) {
|
|
|
|
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
|
2022-03-20 22:41:06 +01:00
|
|
|
result = await this.coverManager.uploadCover(libraryItem, req.files.cover)
|
2022-03-13 00:45:32 +01:00
|
|
|
} else {
|
|
|
|
return res.status(400).send('Invalid request no file or url')
|
|
|
|
}
|
|
|
|
|
|
|
|
if (result && result.error) {
|
|
|
|
return res.status(400).send(result.error)
|
|
|
|
} else if (!result || !result.cover) {
|
|
|
|
return res.status(500).send('Unknown error occurred')
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
2022-03-13 00:45:32 +01:00
|
|
|
res.json({
|
|
|
|
success: true,
|
|
|
|
cover: result.cover
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// PATCH: api/items/:id/cover
|
|
|
|
async updateCover(req, res) {
|
2023-04-09 22:01:14 +02:00
|
|
|
const libraryItem = req.libraryItem
|
2022-03-13 00:45:32 +01:00
|
|
|
if (!req.body.cover) {
|
2023-04-09 22:01:14 +02:00
|
|
|
return res.status(400).send('Invalid request no cover path')
|
2022-03-13 00:45:32 +01:00
|
|
|
}
|
|
|
|
|
2023-04-09 22:01:14 +02:00
|
|
|
const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
|
2022-03-13 00:45:32 +01:00
|
|
|
if (validationResult.error) {
|
|
|
|
return res.status(500).send(validationResult.error)
|
|
|
|
}
|
|
|
|
if (validationResult.updated) {
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
2022-03-13 00:45:32 +01:00
|
|
|
}
|
|
|
|
res.json({
|
|
|
|
success: true,
|
|
|
|
cover: validationResult.cover
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// DELETE: api/items/:id/cover
|
|
|
|
async removeCover(req, res) {
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
|
|
|
|
if (libraryItem.media.coverPath) {
|
|
|
|
libraryItem.updateMediaCover('')
|
|
|
|
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
2022-03-13 00:45:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
res.sendStatus(200)
|
|
|
|
}
|
|
|
|
|
2022-03-11 01:45:02 +01:00
|
|
|
// GET api/items/:id/cover
|
|
|
|
async getCover(req, res) {
|
2022-12-04 23:23:15 +01:00
|
|
|
const { query: { width, height, format, raw }, libraryItem } = req
|
|
|
|
|
|
|
|
if (raw) { // any value
|
|
|
|
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
|
Implement X-Accel Redirect
This patch implements [X-Accel](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/)
redirect headers as an optional way for offloading static file delivery
from Express to Nginx, which is far better optimized for static file
delivery.
This provides a really easy to configure way for getting a huge
performance boost over delivering all files through Audiobookshelf.
How it works
------------
The way this works is basically that Audiobookshelf gets an HTTP request
for delivering a static file (let's say an audiobook). It will first
check the user is authorized and then convert the API path to a local
file path.
Now, instead of reading and delivering the file, Audiobookshelf will
return just the HTTP header with an additional `X-Accel-Redirect`
pointing to the file location on the file syste.
This header is picked up by Nginx which will then deliver the file.
Configuration
-------------
The configuration for this is very simple. You need to run Nginx as
reverse proxy and it must have access to your Audiobookshelf data
folder.
You then configure Audiobookshelf to use X-Accel by setting
`USE_X_ACCEL=/protected`. The path is the internal redirect path used by
Nginx.
In the Nginx configuration you then configure this location and map it
to the storage area to serve like this:
```
location /protected/ {
internal;
alias /;
}
```
That's all.
Impact
------
I just did a very simple performance test, downloading a 1170620819
bytes large audiobook file from another machine on the same network
like this, using `time -p` to measure how log the process took:
```sh
URL='https://url to audiobook…'
for i in `seq 1 50`
do
echo "$i"
curl -s -o /dev/null "${URL}"
done
```
This sequential test with 50 iterations and without x-accel resulted in:
```
real 413.42
user 197.11
sys 82.04
```
That is an average download speed of about 1080 MBit/s.
With X-Accel enabled, serving the files through Nginx, the same test
yielded the following results:
```
real 200.37
user 86.95
sys 29.79
```
That is an average download speed of about 2229 MBit/s, more than
doubling the previous speed.
I have also run the same test with 4 parallel processes and 25 downloads
each. Without x-accel, that test resulted in:
```
real 364.89
user 273.09
sys 112.75
```
That is an average speed of about 2448 MBit/s.
With X-Accel enabled, the parallel test also shows a significant
speedup:
```
real 167.19
user 195.62
sys 78.61
```
That is an average speed of about 5342 MBit/s.
While doing that, I also peaked at the system load which was a bit lower
when using X-Accel. Even though the system was delivering far more data.
But I just looked at the `load1` values and did not build a proper test
for that. That means, I cant provide any definitive data.
Supported Media
---------------
The current implementation works for audio files and book covers. There
are other media files which would benefit from this mechanism like feed
covers or author pictures.
But that's something for a future developer ;-)
2022-11-25 23:41:35 +01:00
|
|
|
if (global.XAccel) {
|
|
|
|
Logger.debug(`Use X-Accel to serve static file ${libraryItem.media.coverPath}`)
|
2023-02-04 00:50:42 +01:00
|
|
|
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryItem.media.coverPath }).send()
|
Implement X-Accel Redirect
This patch implements [X-Accel](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/)
redirect headers as an optional way for offloading static file delivery
from Express to Nginx, which is far better optimized for static file
delivery.
This provides a really easy to configure way for getting a huge
performance boost over delivering all files through Audiobookshelf.
How it works
------------
The way this works is basically that Audiobookshelf gets an HTTP request
for delivering a static file (let's say an audiobook). It will first
check the user is authorized and then convert the API path to a local
file path.
Now, instead of reading and delivering the file, Audiobookshelf will
return just the HTTP header with an additional `X-Accel-Redirect`
pointing to the file location on the file syste.
This header is picked up by Nginx which will then deliver the file.
Configuration
-------------
The configuration for this is very simple. You need to run Nginx as
reverse proxy and it must have access to your Audiobookshelf data
folder.
You then configure Audiobookshelf to use X-Accel by setting
`USE_X_ACCEL=/protected`. The path is the internal redirect path used by
Nginx.
In the Nginx configuration you then configure this location and map it
to the storage area to serve like this:
```
location /protected/ {
internal;
alias /;
}
```
That's all.
Impact
------
I just did a very simple performance test, downloading a 1170620819
bytes large audiobook file from another machine on the same network
like this, using `time -p` to measure how log the process took:
```sh
URL='https://url to audiobook…'
for i in `seq 1 50`
do
echo "$i"
curl -s -o /dev/null "${URL}"
done
```
This sequential test with 50 iterations and without x-accel resulted in:
```
real 413.42
user 197.11
sys 82.04
```
That is an average download speed of about 1080 MBit/s.
With X-Accel enabled, serving the files through Nginx, the same test
yielded the following results:
```
real 200.37
user 86.95
sys 29.79
```
That is an average download speed of about 2229 MBit/s, more than
doubling the previous speed.
I have also run the same test with 4 parallel processes and 25 downloads
each. Without x-accel, that test resulted in:
```
real 364.89
user 273.09
sys 112.75
```
That is an average speed of about 2448 MBit/s.
With X-Accel enabled, the parallel test also shows a significant
speedup:
```
real 167.19
user 195.62
sys 78.61
```
That is an average speed of about 5342 MBit/s.
While doing that, I also peaked at the system load which was a bit lower
when using X-Accel. Even though the system was delivering far more data.
But I just looked at the `load1` values and did not build a proper test
for that. That means, I cant provide any definitive data.
Supported Media
---------------
The current implementation works for audio files and book covers. There
are other media files which would benefit from this mechanism like feed
covers or author pictures.
But that's something for a future developer ;-)
2022-11-25 23:41:35 +01:00
|
|
|
}
|
2022-12-04 23:23:15 +01:00
|
|
|
return res.sendFile(libraryItem.media.coverPath)
|
|
|
|
}
|
2022-03-11 01:45:02 +01:00
|
|
|
|
|
|
|
const options = {
|
|
|
|
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
|
|
|
height: height ? parseInt(height) : null,
|
|
|
|
width: width ? parseInt(width) : null
|
|
|
|
}
|
|
|
|
return this.cacheManager.handleCoverCache(res, libraryItem, options)
|
|
|
|
}
|
|
|
|
|
2022-03-13 02:59:35 +01:00
|
|
|
// GET: api/items/:id/stream
|
|
|
|
openStream(req, res) {
|
2022-03-16 01:28:54 +01:00
|
|
|
// this.streamManager.openStreamApiRequest(res, req.user, req.libraryItem)
|
|
|
|
res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
2022-03-18 01:10:47 +01:00
|
|
|
// POST: api/items/:id/play
|
2022-03-16 01:28:54 +01:00
|
|
|
startPlaybackSession(req, res) {
|
2022-05-31 02:26:53 +02:00
|
|
|
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
|
2022-03-26 17:59:34 +01:00
|
|
|
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
2022-03-18 01:10:47 +01:00
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
2022-05-27 02:09:46 +02:00
|
|
|
|
|
|
|
this.playbackSessionManager.startSessionRequest(req, res, null)
|
2022-03-26 23:41:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// POST: api/items/:id/play/:episodeId
|
|
|
|
startEpisodePlaybackSession(req, res) {
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
if (!libraryItem.media.numTracks) {
|
|
|
|
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`)
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
var episodeId = req.params.episodeId
|
|
|
|
if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
|
|
|
|
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`)
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
|
2022-05-27 02:09:46 +02:00
|
|
|
this.playbackSessionManager.startSessionRequest(req, res, episodeId)
|
2022-03-26 17:59:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// PATCH: api/items/:id/tracks
|
|
|
|
async updateTracks(req, res) {
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
var orderedFileData = req.body.orderedFileData
|
|
|
|
if (!libraryItem.media.updateAudioTracks) {
|
|
|
|
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`)
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
libraryItem.media.updateAudioTracks(orderedFileData)
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
2022-03-26 17:59:34 +01:00
|
|
|
res.json(libraryItem.toJSON())
|
2022-03-13 02:59:35 +01:00
|
|
|
}
|
|
|
|
|
2022-03-14 01:34:31 +01:00
|
|
|
// POST api/items/:id/match
|
|
|
|
async match(req, res) {
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
|
|
|
|
var options = req.body || {}
|
2022-04-21 01:05:09 +02:00
|
|
|
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
2022-03-14 01:34:31 +01:00
|
|
|
res.json(matchResult)
|
|
|
|
}
|
|
|
|
|
2022-03-13 23:10:48 +01:00
|
|
|
// POST: api/items/batch/delete
|
|
|
|
async batchDelete(req, res) {
|
|
|
|
if (!req.user.canDelete) {
|
|
|
|
Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
2023-04-14 23:44:41 +02:00
|
|
|
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
2022-03-13 23:10:48 +01:00
|
|
|
|
2023-04-14 23:44:41 +02:00
|
|
|
const { libraryItemIds } = req.body
|
2022-03-13 23:10:48 +01:00
|
|
|
if (!libraryItemIds || !libraryItemIds.length) {
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
2023-04-14 23:44:41 +02:00
|
|
|
const itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
2022-03-13 23:10:48 +01:00
|
|
|
if (!itemsToDelete.length) {
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
for (let i = 0; i < itemsToDelete.length; i++) {
|
2023-04-14 23:44:41 +02:00
|
|
|
const libraryItemPath = itemsToDelete[i].path
|
2022-03-13 23:10:48 +01:00
|
|
|
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
|
|
|
|
await this.handleDeleteLibraryItem(itemsToDelete[i])
|
2023-04-14 23:44:41 +02:00
|
|
|
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)
|
|
|
|
})
|
|
|
|
}
|
2022-03-13 23:10:48 +01:00
|
|
|
}
|
|
|
|
res.sendStatus(200)
|
|
|
|
}
|
|
|
|
|
|
|
|
// POST: api/items/batch/update
|
|
|
|
async batchUpdate(req, res) {
|
|
|
|
var updatePayloads = req.body
|
|
|
|
if (!updatePayloads || !updatePayloads.length) {
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
|
|
|
var itemsUpdated = 0
|
|
|
|
|
|
|
|
for (let i = 0; i < updatePayloads.length; i++) {
|
|
|
|
var mediaPayload = updatePayloads[i].mediaPayload
|
|
|
|
var libraryItem = this.db.libraryItems.find(_li => _li.id === updatePayloads[i].id)
|
|
|
|
if (!libraryItem) return null
|
|
|
|
|
|
|
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
|
|
|
|
|
|
|
var hasUpdates = libraryItem.media.update(mediaPayload)
|
|
|
|
if (hasUpdates) {
|
|
|
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
2022-03-13 23:10:48 +01:00
|
|
|
itemsUpdated++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
success: true,
|
|
|
|
updates: itemsUpdated
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// POST: api/items/batch/get
|
|
|
|
async batchGet(req, res) {
|
2022-12-13 00:36:53 +01:00
|
|
|
const libraryItemIds = req.body.libraryItemIds || []
|
2022-03-13 23:10:48 +01:00
|
|
|
if (!libraryItemIds.length) {
|
|
|
|
return res.status(403).send('Invalid payload')
|
|
|
|
}
|
2022-12-13 00:36:53 +01:00
|
|
|
const libraryItems = []
|
2022-11-19 17:20:10 +01:00
|
|
|
libraryItemIds.forEach((lid) => {
|
|
|
|
const li = this.db.libraryItems.find(_li => _li.id === lid)
|
|
|
|
if (li) libraryItems.push(li.toJSONExpanded())
|
|
|
|
})
|
2022-11-29 18:37:45 +01:00
|
|
|
res.json({
|
2022-12-13 00:36:53 +01:00
|
|
|
libraryItems
|
2022-11-29 18:37:45 +01:00
|
|
|
})
|
2022-03-13 23:10:48 +01:00
|
|
|
}
|
|
|
|
|
2022-09-23 18:51:34 +02:00
|
|
|
// POST: api/items/batch/quickmatch
|
|
|
|
async batchQuickMatch(req, res) {
|
2022-09-24 23:17:36 +02:00
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.warn('User other than admin attempted to batch quick match library items', req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
2022-09-25 22:56:06 +02:00
|
|
|
|
2023-05-27 21:51:03 +02:00
|
|
|
let itemsUpdated = 0
|
|
|
|
let itemsUnmatched = 0
|
2022-09-23 18:51:34 +02:00
|
|
|
|
2023-05-27 21:51:03 +02:00
|
|
|
const options = req.body.options || {}
|
|
|
|
if (!req.body.libraryItemIds?.length) {
|
|
|
|
return res.sendStatus(400)
|
2022-09-23 18:51:34 +02:00
|
|
|
}
|
2023-05-27 21:51:03 +02:00
|
|
|
|
|
|
|
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
|
|
|
|
if (!libraryItems?.length) {
|
|
|
|
return res.sendStatus(400)
|
|
|
|
}
|
|
|
|
|
2022-09-25 00:38:18 +02:00
|
|
|
res.sendStatus(200)
|
2022-09-25 22:56:06 +02:00
|
|
|
|
2023-05-27 21:51:03 +02:00
|
|
|
for (const libraryItem of libraryItems) {
|
|
|
|
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
2022-09-25 22:56:06 +02:00
|
|
|
if (matchResult.updated) {
|
|
|
|
itemsUpdated++
|
|
|
|
} else if (matchResult.warning) {
|
|
|
|
itemsUnmatched++
|
|
|
|
}
|
2022-09-23 20:37:30 +02:00
|
|
|
}
|
2022-09-25 22:56:06 +02:00
|
|
|
|
2023-05-27 21:51:03 +02:00
|
|
|
const result = {
|
2022-09-23 20:37:30 +02:00
|
|
|
success: itemsUpdated > 0,
|
2022-09-24 19:57:09 +02:00
|
|
|
updates: itemsUpdated,
|
2022-09-24 23:17:36 +02:00
|
|
|
unmatched: itemsUnmatched
|
2022-09-25 00:38:44 +02:00
|
|
|
}
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
|
2022-09-23 18:51:34 +02:00
|
|
|
}
|
|
|
|
|
2023-05-27 21:51:03 +02:00
|
|
|
// POST: api/items/batch/scan
|
|
|
|
async batchScan(req, res) {
|
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.warn('User other than admin attempted to batch scan library items', req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!req.body.libraryItemIds?.length) {
|
|
|
|
return res.sendStatus(400)
|
|
|
|
}
|
|
|
|
|
|
|
|
const libraryItems = req.body.libraryItemIds.map(lid => this.db.getLibraryItem(lid)).filter(li => li)
|
|
|
|
if (!libraryItems?.length) {
|
|
|
|
return res.sendStatus(400)
|
|
|
|
}
|
|
|
|
|
|
|
|
res.sendStatus(200)
|
|
|
|
|
|
|
|
for (const libraryItem of libraryItems) {
|
|
|
|
if (libraryItem.isFile) {
|
|
|
|
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
|
|
|
} else {
|
|
|
|
await this.scanner.scanLibraryItemByRequest(libraryItem)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-03-14 01:34:31 +01:00
|
|
|
// DELETE: api/items/all
|
|
|
|
async deleteAll(req, res) {
|
2022-05-04 02:16:16 +02:00
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.warn('User other than admin attempted to delete all library items', req.user)
|
2022-03-14 01:34:31 +01:00
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
Logger.info('Removing all Library Items')
|
|
|
|
var success = await this.db.recreateLibraryItemsDb()
|
|
|
|
if (success) res.sendStatus(200)
|
|
|
|
else res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
2023-02-04 00:50:42 +01:00
|
|
|
// POST: api/items/:id/scan (admin)
|
2022-03-18 17:51:55 +01:00
|
|
|
async scan(req, res) {
|
2022-05-04 02:16:16 +02:00
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
|
2022-03-18 17:51:55 +01:00
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
2022-04-28 02:42:34 +02:00
|
|
|
|
|
|
|
if (req.libraryItem.isFile) {
|
|
|
|
Logger.error(`[LibraryItemController] Re-scanning file library items not yet supported`)
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
2023-05-27 21:51:03 +02:00
|
|
|
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
|
2022-03-18 17:51:55 +01:00
|
|
|
res.json({
|
|
|
|
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-09-25 22:56:06 +02:00
|
|
|
getToneMetadataObject(req, res) {
|
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
|
|
|
Logger.error(`[LibraryItemController] Invalid library item`)
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
|
|
|
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
|
|
|
|
}
|
|
|
|
|
2022-05-11 00:03:41 +02:00
|
|
|
// POST: api/items/:id/chapters
|
|
|
|
async updateMediaChapters(req, res) {
|
|
|
|
if (!req.user.canUpdate) {
|
|
|
|
Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
|
|
|
Logger.error(`[LibraryItemController] Invalid library item`)
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
2023-04-09 19:47:36 +02:00
|
|
|
if (!req.body.chapters) {
|
2022-05-11 00:03:41 +02:00
|
|
|
Logger.error(`[LibraryItemController] Invalid payload`)
|
|
|
|
return res.sendStatus(400)
|
|
|
|
}
|
|
|
|
|
2023-04-09 19:47:36 +02:00
|
|
|
const chapters = req.body.chapters || []
|
2022-05-11 00:03:41 +02:00
|
|
|
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
|
|
|
if (wasUpdated) {
|
|
|
|
await this.db.updateLibraryItem(req.libraryItem)
|
2022-11-24 22:53:58 +01:00
|
|
|
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
2022-05-11 00:03:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
success: true,
|
|
|
|
updated: wasUpdated
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-10-02 22:24:32 +02:00
|
|
|
async toneScan(req, res) {
|
|
|
|
if (!req.libraryItem.media.audioFiles.length) {
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
|
|
|
|
const audioFileIndex = isNullOrNaN(req.params.index) ? 1 : Number(req.params.index)
|
|
|
|
const audioFile = req.libraryItem.media.audioFiles.find(af => af.index === audioFileIndex)
|
|
|
|
if (!audioFile) {
|
|
|
|
Logger.error(`[LibraryItemController] toneScan: Audio file not found with index ${audioFileIndex}`)
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
|
|
|
|
const toneData = await this.scanner.probeAudioFileWithTone(audioFile)
|
|
|
|
res.json(toneData)
|
|
|
|
}
|
|
|
|
|
2023-05-28 19:34:22 +02:00
|
|
|
/**
|
|
|
|
* GET api/items/:id/file/:fileid
|
|
|
|
*
|
|
|
|
* @param {express.Request} req
|
|
|
|
* @param {express.Response} res
|
|
|
|
*/
|
|
|
|
async getLibraryFile(req, res) {
|
|
|
|
const libraryFile = req.libraryFile
|
|
|
|
|
|
|
|
if (global.XAccel) {
|
|
|
|
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
|
|
|
|
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
|
2023-04-14 01:03:39 +02:00
|
|
|
}
|
|
|
|
|
2023-05-28 19:34:22 +02:00
|
|
|
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
|
|
|
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
|
|
|
|
if (audioMimeType) {
|
|
|
|
res.setHeader('Content-Type', audioMimeType)
|
|
|
|
}
|
|
|
|
res.sendFile(libraryFile.metadata.path)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* DELETE api/items/:id/file/:fileid
|
|
|
|
*
|
|
|
|
* @param {express.Request} req
|
|
|
|
* @param {express.Response} res
|
|
|
|
*/
|
|
|
|
async deleteLibraryFile(req, res) {
|
|
|
|
const libraryFile = req.libraryFile
|
|
|
|
|
|
|
|
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file delete at "${libraryFile.metadata.path}"`)
|
|
|
|
|
2023-04-14 23:44:41 +02:00
|
|
|
await fs.remove(libraryFile.metadata.path).catch((error) => {
|
|
|
|
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
|
|
|
|
})
|
2023-05-28 19:34:22 +02:00
|
|
|
req.libraryItem.removeLibraryFile(req.params.fileid)
|
2023-04-14 01:03:39 +02:00
|
|
|
|
2023-05-28 19:34:22 +02:00
|
|
|
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
|
2023-04-14 01:03:39 +02:00
|
|
|
// If book has no more media files then mark it as missing
|
|
|
|
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
|
|
|
|
req.libraryItem.setMissing()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
req.libraryItem.updatedAt = Date.now()
|
|
|
|
await this.db.updateLibraryItem(req.libraryItem)
|
|
|
|
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
|
|
|
res.sendStatus(200)
|
|
|
|
}
|
|
|
|
|
2023-05-28 19:34:22 +02:00
|
|
|
/**
|
|
|
|
* GET api/items/:id/file/:fileid/download
|
|
|
|
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
|
|
|
|
* @param {express.Request} req
|
|
|
|
* @param {express.Response} res
|
|
|
|
*/
|
|
|
|
async downloadLibraryFile(req, res) {
|
|
|
|
const libraryFile = req.libraryFile
|
|
|
|
|
|
|
|
if (!req.user.canDownload) {
|
|
|
|
Logger.error(`[LibraryItemController] User without download permission attempted to download file "${libraryFile.metadata.path}"`, req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file download at "${libraryFile.metadata.path}"`)
|
|
|
|
|
|
|
|
if (global.XAccel) {
|
|
|
|
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
|
|
|
|
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
|
|
|
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
|
|
|
|
if (audioMimeType) {
|
|
|
|
res.setHeader('Content-Type', audioMimeType)
|
|
|
|
}
|
|
|
|
|
|
|
|
res.download(libraryFile.metadata.path, libraryFile.metadata.filename)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* GET api/items/:id/ebook
|
|
|
|
*
|
|
|
|
* @param {express.Request} req
|
|
|
|
* @param {express.Response} res
|
|
|
|
*/
|
2023-05-28 17:47:28 +02:00
|
|
|
async getEBookFile(req, res) {
|
|
|
|
const ebookFile = req.libraryItem.media.ebookFile
|
|
|
|
if (!ebookFile) {
|
|
|
|
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
const ebookFilePath = ebookFile.metadata.path
|
2023-05-28 19:34:22 +02:00
|
|
|
|
|
|
|
if (global.XAccel) {
|
|
|
|
Logger.debug(`Use X-Accel to serve static file ${ebookFilePath}`)
|
|
|
|
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + ebookFilePath }).send()
|
|
|
|
}
|
|
|
|
|
2023-05-28 17:47:28 +02:00
|
|
|
res.sendFile(ebookFilePath)
|
|
|
|
}
|
|
|
|
|
2022-03-11 01:45:02 +01:00
|
|
|
middleware(req, res, next) {
|
2023-05-28 19:34:22 +02:00
|
|
|
req.libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
|
|
|
|
if (!req.libraryItem?.media) return res.sendStatus(404)
|
2022-03-11 01:45:02 +01:00
|
|
|
|
2022-03-20 12:29:08 +01:00
|
|
|
// Check user can access this library item
|
2023-05-28 19:34:22 +02:00
|
|
|
if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
|
2022-03-20 12:29:08 +01:00
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
2023-05-28 19:34:22 +02:00
|
|
|
// For library file routes, get the library file
|
|
|
|
if (req.params.fileid) {
|
|
|
|
req.libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
|
|
|
|
if (!req.libraryFile) {
|
|
|
|
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
|
|
|
|
return res.sendStatus(404)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-21 14:24:54 +02:00
|
|
|
if (req.path.includes('/play')) {
|
|
|
|
// allow POST requests using /play and /play/:episodeId
|
|
|
|
} else if (req.method == 'DELETE' && !req.user.canDelete) {
|
2022-03-13 00:45:32 +01:00
|
|
|
Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
2022-04-21 14:24:54 +02:00
|
|
|
Logger.warn('[LibraryItemController] User attempted to update without permission', req.user.username)
|
2022-03-13 00:45:32 +01:00
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
2022-03-11 01:45:02 +01:00
|
|
|
next()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = new LibraryItemController()
|