2024-08-11 22:15:34 +02:00
const { Request , Response , NextFunction } = require ( 'express' )
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' )
2024-08-21 04:00:29 +02:00
const uaParserJs = require ( '../libs/uaParser' )
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-07-05 01:14:44 +02:00
const Database = require ( '../Database' )
2022-11-24 22:53:58 +01:00
2023-04-10 00:05:35 +02:00
const zipHelpers = require ( '../utils/zipHelpers' )
2023-07-05 01:14:44 +02:00
const { reqSupportsWebp } = require ( '../utils/index' )
2024-08-21 04:00:29 +02:00
const { ScanResult , AudioMimeType } = require ( '../utils/constants' )
2023-09-18 22:08:19 +02:00
const { getAudioMimeTypeFromExtname , encodeUriPath } = require ( '../utils/fileUtils' )
2023-09-04 00:51:58 +02:00
const LibraryItemScanner = require ( '../scanner/LibraryItemScanner' )
2023-09-04 20:59:37 +02:00
const AudioFileScanner = require ( '../scanner/AudioFileScanner' )
2023-09-07 00:48:50 +02:00
const Scanner = require ( '../scanner/Scanner' )
const CacheManager = require ( '../managers/CacheManager' )
const CoverManager = require ( '../managers/CoverManager' )
2024-06-22 23:42:13 +02:00
const ShareManager = require ( '../managers/ShareManager' )
2022-03-11 01:45:02 +01:00
2024-08-11 22:15:34 +02:00
/ * *
2024-08-12 00:01:25 +02:00
* @ typedef RequestUserObject
2024-08-11 23:07:29 +02:00
* @ property { import ( '../models/User' ) } user
2024-08-11 22:15:34 +02:00
*
2024-08-12 00:01:25 +02:00
* @ typedef { Request & RequestUserObject } RequestWithUser
2024-08-11 22:15:34 +02:00
* /
2022-03-11 01:45:02 +01:00
class LibraryItemController {
2024-06-22 23:42:13 +02:00
constructor ( ) { }
2022-03-11 01:45:02 +01:00
2023-09-03 17:04:14 +02:00
/ * *
* GET : / a p i / i t e m s / : i d
* Optional query params :
2024-07-02 00:26:13 +02:00
* ? include = progress , rssfeed , downloads , share
2023-09-03 17:04:14 +02:00
* ? expanded = 1
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-09-03 17:04:14 +02:00
* /
2023-07-17 23:48:46 +02:00
async 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
2024-08-11 23:07:29 +02:00
item . userMediaProgress = req . user . getOldMediaProgress ( item . id , episodeId )
2022-04-26 02:03:26 +02:00
}
2022-05-02 23:42:30 +02:00
if ( includeEntities . includes ( 'rssfeed' ) ) {
2023-07-17 23:48:46 +02:00
const feedData = await this . rssFeedManager . findFeedForEntityId ( item . id )
item . rssFeed = feedData ? . toJSONMinified ( ) || null
2022-05-02 23:42:30 +02:00
}
2024-08-11 23:07:29 +02:00
if ( item . mediaType === 'book' && req . user . isAdminOrUp && includeEntities . includes ( 'share' ) ) {
2024-06-22 23:42:13 +02:00
item . mediaItemShare = ShareManager . findByMediaItemId ( item . media . id )
}
2023-09-03 17:04:14 +02:00
if ( item . mediaType === 'podcast' && includeEntities . includes ( 'downloads' ) ) {
2023-03-05 17:35:34 +01:00
const downloadsInQueue = this . podcastManager . getEpisodeDownloadsInQueue ( req . libraryItem . id )
2024-06-22 23:42:13 +02:00
item . episodeDownloadsQueued = downloadsInQueue . map ( ( d ) => d . toJSONForClient ( ) )
2023-03-05 17:35:34 +01:00
if ( this . podcastManager . currentDownload ? . libraryItemId === req . libraryItem . id ) {
item . episodesDownloading = [ this . podcastManager . currentDownload . toJSONForClient ( ) ]
}
2022-03-21 11:08:33 +01:00
}
2023-12-31 21:51:01 +01:00
2022-03-21 11:08:33 +01:00
return res . json ( item )
}
2022-03-11 01:45:02 +01:00
res . json ( req . libraryItem )
}
2024-08-11 23:07:29 +02:00
/ * *
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
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 ) ) {
2023-09-07 00:48:50 +02:00
await CacheManager . purgeCoverCache ( libraryItem . id )
2022-03-12 02:46:32 +01:00
}
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 ` )
2023-07-05 01:14:44 +02:00
await Database . 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 ( ) )
}
2024-08-04 00:09:17 +02:00
/ * *
* DELETE : / a p i / i t e m s / : i d
* Delete library item . Will delete from database and file system if hard delete is requested .
* Optional query params :
* ? hard = 1
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2024-08-04 00:09:17 +02:00
* /
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
2024-08-04 00:09:17 +02:00
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 )
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 )
} )
}
2023-08-20 20:16:53 +02:00
await Database . resetLibraryIssuesFilterData ( req . libraryItem . libraryId )
2022-03-13 00:45:32 +01:00
res . sendStatus ( 200 )
}
2024-10-28 07:03:31 +01:00
# 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' )
}
}
}
2023-10-11 00:51:52 +02:00
/ * *
* GET : / a p i / i t e m s / : i d / d o w n l o a d
* Download library item . Zip file if multiple files .
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-10-11 00:51:52 +02:00
* /
2024-10-28 07:03:31 +01:00
async download ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . canDownload ) {
Logger . warn ( ` User " ${ req . user . username } " attempted to download without permission ` )
2023-04-10 00:05:35 +02:00
return res . sendStatus ( 403 )
}
2024-08-09 23:48:21 +02:00
const libraryItemPath = req . libraryItem . path
const itemTitle = req . libraryItem . media . metadata . title
2023-04-10 00:05:35 +02:00
2024-10-28 07:03:31 +01:00
Logger . info ( ` [LibraryItemController] User " ${ req . user . username } " requested download for item " ${ itemTitle } " at " ${ libraryItemPath } " ` )
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 )
2023-10-11 00:51:52 +02:00
}
2024-10-28 07:03:31 +01:00
Logger . info ( ` [LibraryItemController] Downloaded item " ${ itemTitle } " at " ${ libraryItemPath } " ` )
} catch ( error ) {
Logger . error ( ` [LibraryItemController] Download failed for item " ${ itemTitle } " at " ${ libraryItemPath } " ` , error )
this . # handleDownloadError ( error , res )
2023-10-11 00:51:52 +02:00
}
2023-04-10 00:05:35 +02:00
}
2024-04-20 18:34:21 +02:00
/ * *
* PATCH : /items/ : id / media
* Update media for a library item . Will create new authors & series when necessary
2024-06-22 23:42:13 +02:00
*
2024-08-31 20:27:48 +02:00
* @ this { import ( '../routers/ApiRouter' ) }
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2024-04-20 18:34:21 +02:00
* /
2022-03-12 02:46:32 +01:00
async updateMedia ( req , res ) {
2022-12-29 01:08:03 +01:00
const libraryItem = req . libraryItem
const mediaPayload = req . body
2023-07-17 15:09:08 +02:00
2024-02-01 11:03:12 +01:00
if ( mediaPayload . url ) {
await LibraryItemController . prototype . uploadCover . bind ( this ) ( req , res , false )
2024-04-20 18:34:21 +02:00
if ( res . writableEnded || res . headersSent ) return
2024-02-01 11:03:12 +01:00
}
2022-08-20 01:41:58 +02:00
// Book specific
2022-04-14 01:13:39 +02:00
if ( libraryItem . isBook ) {
2023-07-08 16:57:32 +02:00
await this . createAuthorsAndSeriesForItemUpdate ( mediaPayload , libraryItem . libraryId )
2022-04-14 01:13:39 +02:00
}
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 ) {
2024-06-22 23:42:13 +02:00
const seriesIdsInUpdate = mediaPayload . metadata . series ? . map ( ( se ) => se . id ) || [ ]
seriesRemoved = libraryItem . media . metadata . series . filter ( ( se ) => ! seriesIdsInUpdate . includes ( se . id ) )
2022-12-31 23:58:19 +01:00
}
2024-08-31 20:27:48 +02:00
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 ) )
}
2024-02-01 11:03:12 +01:00
const hasUpdates = libraryItem . media . update ( mediaPayload ) || mediaPayload . url
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. ` )
2024-06-22 23:42:13 +02:00
await this . checkRemoveEmptySeries (
libraryItem . media . id ,
seriesRemoved . map ( ( se ) => se . id )
)
2022-12-31 23:58:19 +01:00
}
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 } ` )
2023-07-05 01:14:44 +02:00
await Database . updateLibraryItem ( libraryItem )
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'item_updated' , libraryItem . toJSONExpanded ( ) )
2024-08-31 20:27:48 +02:00
if ( authorsRemoved . length ) {
// Check remove empty authors
Logger . debug ( ` [LibraryItemController] Authors were removed from book. Check if authors are now empty. ` )
await this . checkRemoveAuthorsWithNoBooks (
libraryItem . libraryId ,
authorsRemoved . map ( ( au ) => au . id )
)
}
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
}
2024-08-11 23:07:29 +02:00
/ * *
* POST : / a p i / i t e m s / : i d / c o v e r
*
* @ param { RequestWithUser } req
* @ param { Response } res
* @ param { boolean } [ updateAndReturnJson = true ]
* /
2024-02-01 11:03:12 +01:00
async uploadCover ( req , res , updateAndReturnJson = true ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . canUpload ) {
Logger . warn ( ` User " ${ req . user . username } " attempted to upload a cover without permission ` )
2022-03-13 00:45:32 +01:00
return res . sendStatus ( 403 )
}
2023-10-13 23:33:47 +02:00
let libraryItem = req . libraryItem
2022-03-13 00:45:32 +01:00
2023-10-13 23:33:47 +02:00
let result = null
if ( req . body ? . url ) {
2022-03-13 00:45:32 +01:00
Logger . debug ( ` [LibraryItemController] Requesting download cover from url " ${ req . body . url } " ` )
2023-09-07 00:48:50 +02:00
result = await CoverManager . downloadCoverFromUrl ( libraryItem , req . body . url )
2023-10-13 23:33:47 +02:00
} else if ( req . files ? . cover ) {
2022-03-13 00:45:32 +01:00
Logger . debug ( ` [LibraryItemController] Handling uploaded cover ` )
2023-09-07 00:48:50 +02:00
result = await 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' )
}
2023-10-13 23:33:47 +02:00
if ( result ? . error ) {
2022-03-13 00:45:32 +01:00
return res . status ( 400 ) . send ( result . error )
2023-10-13 23:33:47 +02:00
} else if ( ! result ? . cover ) {
2022-03-13 00:45:32 +01:00
return res . status ( 500 ) . send ( 'Unknown error occurred' )
}
2024-02-01 11:03:12 +01:00
if ( updateAndReturnJson ) {
await Database . updateLibraryItem ( libraryItem )
SocketAuthority . emitter ( 'item_updated' , libraryItem . toJSONExpanded ( ) )
res . json ( {
success : true ,
cover : result . cover
} )
}
2022-03-13 00:45:32 +01:00
}
2024-08-11 23:07:29 +02:00
/ * *
* PATCH : / a p i / i t e m s / : i d / c o v e r
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-13 00:45:32 +01:00
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-09-07 00:48:50 +02:00
const validationResult = await 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 ) {
2023-07-05 01:14:44 +02:00
await Database . 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
} )
}
2024-08-11 23:07:29 +02:00
/ * *
* DELETE : / a p i / i t e m s / : i d / c o v e r
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-13 00:45:32 +01:00
async removeCover ( req , res ) {
var libraryItem = req . libraryItem
if ( libraryItem . media . coverPath ) {
libraryItem . updateMediaCover ( '' )
2023-09-07 00:48:50 +02:00
await CacheManager . purgeCoverCache ( libraryItem . id )
2023-07-05 01:14:44 +02:00
await Database . 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 )
}
2023-09-21 23:57:48 +02:00
/ * *
2024-08-11 23:07:29 +02:00
* GET : / a p i / i t e m s / : i d / c o v e r
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-09-21 23:57:48 +02:00
* /
2022-03-11 01:45:02 +01:00
async getCover ( req , res ) {
2024-06-22 23:42:13 +02:00
const {
query : { width , height , format , raw }
} = req
2023-09-21 23:57:48 +02:00
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 )
}
2022-12-04 23:23:15 +01:00
2023-09-21 23:57:48 +02:00
// Check if user can access this library item
2024-08-11 23:07:29 +02:00
if ( ! req . user . checkCanAccessLibraryItem ( libraryItem ) ) {
2023-09-21 23:57:48 +02:00
return res . sendStatus ( 403 )
}
2022-12-04 23:23:15 +01:00
2023-09-21 23:57:48 +02:00
// Check if library item media has a cover path
2024-06-22 23:42:13 +02:00
if ( ! libraryItem . media . coverPath || ! ( await fs . pathExists ( libraryItem . media . coverPath ) ) ) {
2023-09-21 23:57:48 +02:00
return res . sendStatus ( 404 )
}
2024-06-22 23:42:13 +02:00
if ( req . query . ts ) res . set ( 'Cache-Control' , 'private, max-age=86400' )
2024-02-26 13:00:25 +01:00
2024-06-22 23:42:13 +02:00
if ( raw ) {
// any value
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 ) {
2023-09-18 22:08:19 +02:00
const encodedURI = encodeUriPath ( global . XAccel + libraryItem . media . coverPath )
Logger . debug ( ` Use X-Accel to serve static file ${ encodedURI } ` )
return res . status ( 204 ) . header ( { 'X-Accel-Redirect' : encodedURI } ) . 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
}
2023-09-21 23:57:48 +02:00
return CacheManager . handleCoverCache ( res , libraryItem . id , libraryItem . media . coverPath , options )
2022-03-11 01:45:02 +01:00
}
2024-08-11 22:15:34 +02:00
/ * *
* POST : / a p i / i t e m s / : i d / p l a y
*
* @ this { import ( '../routers/ApiRouter' ) }
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-16 01:28:54 +01:00
startPlaybackSession ( req , res ) {
2024-09-04 00:04:58 +02:00
if ( ! req . libraryItem . media . numTracks ) {
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
}
2024-08-11 22:15:34 +02:00
/ * *
* POST : / a p i / i t e m s / : i d / p l a y / : e p i s o d e I d
*
* @ this { import ( '../routers/ApiRouter' ) }
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-26 23:41:26 +01:00
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
2024-06-22 23:42:13 +02:00
if ( ! libraryItem . media . episodes . find ( ( ep ) => ep . id === episodeId ) ) {
2022-03-26 23:41:26 +01:00
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
}
2024-08-11 23:07:29 +02:00
/ * *
* PATCH : / a p i / i t e m s / : i d / t r a c k s
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-26 17:59:34 +01:00
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 )
2023-07-05 01:14:44 +02:00
await Database . 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
}
2024-08-11 23:07:29 +02:00
/ * *
* POST / api / items / : id / match
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-14 01:34:31 +01:00
async match ( req , res ) {
var libraryItem = req . libraryItem
var options = req . body || { }
2023-09-07 00:48:50 +02:00
var matchResult = await Scanner . quickMatchLibraryItem ( libraryItem , options )
2022-03-14 01:34:31 +01:00
res . json ( matchResult )
}
2024-08-04 00:09:17 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / d e l e t e
* Batch delete library items . Will delete from database and file system if hard delete is requested .
* Optional query params :
* ? hard = 1
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2024-08-04 00:09:17 +02:00
* /
2022-03-13 23:10:48 +01:00
async batchDelete ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . canDelete ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to delete without permission ` )
2022-03-13 23:10:48 +01:00
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
2023-08-13 00:29:08 +02:00
if ( ! libraryItemIds ? . length ) {
return res . status ( 400 ) . send ( 'Invalid request body' )
2022-03-13 23:10:48 +01:00
}
2023-08-20 20:34:03 +02:00
const itemsToDelete = await Database . libraryItemModel . getAllOldLibraryItems ( {
2023-08-13 00:29:08 +02:00
id : libraryItemIds
} )
2022-03-13 23:10:48 +01:00
if ( ! itemsToDelete . length ) {
return res . sendStatus ( 404 )
}
2023-08-13 00:29:08 +02:00
2023-09-02 17:46:47 +02:00
const libraryId = itemsToDelete [ 0 ] . libraryId
2023-08-13 00:29:08 +02:00
for ( const libraryItem of itemsToDelete ) {
const libraryItemPath = libraryItem . path
2024-09-23 23:36:56 +02:00
Logger . info ( ` [LibraryItemController] ( ${ hardDelete ? 'Hard' : 'Soft' } ) deleting Library Item " ${ libraryItem . media . metadata . title } " with id " ${ libraryItem . id } " ` )
2024-08-04 00:09:17 +02:00
const mediaItemIds = libraryItem . mediaType === 'podcast' ? libraryItem . media . episodes . map ( ( ep ) => ep . id ) : [ libraryItem . media . id ]
await this . handleDeleteLibraryItem ( libraryItem . mediaType , libraryItem . id , mediaItemIds )
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
}
2023-08-20 20:16:53 +02:00
2023-09-02 17:46:47 +02:00
await Database . resetLibraryIssuesFilterData ( libraryId )
2022-03-13 23:10:48 +01:00
res . sendStatus ( 200 )
}
2024-08-11 23:07:29 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / u p d a t e
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-13 23:10:48 +01:00
async batchUpdate ( req , res ) {
2023-08-13 00:29:08 +02:00
const updatePayloads = req . body
if ( ! updatePayloads ? . length ) {
2022-03-13 23:10:48 +01:00
return res . sendStatus ( 500 )
}
2023-08-13 00:29:08 +02:00
let itemsUpdated = 0
2022-03-13 23:10:48 +01:00
2023-08-13 00:29:08 +02:00
for ( const updatePayload of updatePayloads ) {
const mediaPayload = updatePayload . mediaPayload
2023-08-20 20:34:03 +02:00
const libraryItem = await Database . libraryItemModel . getOldById ( updatePayload . id )
2022-03-13 23:10:48 +01:00
if ( ! libraryItem ) return null
2023-07-08 16:57:32 +02:00
await this . createAuthorsAndSeriesForItemUpdate ( mediaPayload , libraryItem . libraryId )
2022-03-13 23:10:48 +01:00
2023-08-18 00:58:57 +02:00
let seriesRemoved = [ ]
if ( libraryItem . isBook && mediaPayload . metadata ? . series ) {
2024-06-22 23:42:13 +02:00
const seriesIdsInUpdate = ( mediaPayload . metadata ? . series || [ ] ) . map ( ( se ) => se . id )
seriesRemoved = libraryItem . media . metadata . series . filter ( ( se ) => ! seriesIdsInUpdate . includes ( se . id ) )
2023-08-18 00:58:57 +02:00
}
2023-08-13 00:29:08 +02:00
if ( libraryItem . media . update ( mediaPayload ) ) {
2022-03-13 23:10:48 +01:00
Logger . debug ( ` [LibraryItemController] Updated library item media ${ libraryItem . media . metadata . title } ` )
2023-08-18 00:58:57 +02:00
if ( seriesRemoved . length ) {
// Check remove empty series
Logger . debug ( ` [LibraryItemController] Series was removed from book. Check if series is now empty. ` )
2024-06-22 23:42:13 +02:00
await this . checkRemoveEmptySeries (
libraryItem . media . id ,
seriesRemoved . map ( ( se ) => se . id )
)
2023-08-18 00:58:57 +02:00
}
2023-07-05 01:14:44 +02:00
await Database . 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
} )
}
2024-08-11 23:07:29 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / g e t
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-13 23:10:48 +01:00
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' )
}
2023-08-20 20:34:03 +02:00
const libraryItems = await Database . libraryItemModel . getAllOldLibraryItems ( {
2023-08-13 00:29:08 +02:00
id : libraryItemIds
2022-11-19 17:20:10 +01:00
} )
2022-11-29 18:37:45 +01:00
res . json ( {
2024-06-22 23:42:13 +02:00
libraryItems : libraryItems . map ( ( li ) => li . toJSONExpanded ( ) )
2022-11-29 18:37:45 +01:00
} )
2022-03-13 23:10:48 +01:00
}
2024-08-11 23:07:29 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / q u i c k m a t c h
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-09-23 18:51:34 +02:00
async batchQuickMatch ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . isAdminOrUp ) {
Logger . warn ( ` Non-admin user " ${ req . user . username } " other than admin attempted to batch quick match library items ` )
2022-09-24 23:17:36 +02:00
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
2023-09-04 23:33:55 +02:00
const libraryItems = await Database . libraryItemModel . getAllOldLibraryItems ( {
id : req . body . libraryItemIds
} )
2023-05-27 21:51:03 +02:00
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 ) {
2023-09-07 00:48:50 +02:00
const matchResult = await 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
}
2024-08-11 23:07:29 +02:00
SocketAuthority . clientEmitter ( req . user . id , 'batch_quickmatch_complete' , result )
2022-09-23 18:51:34 +02:00
}
2024-08-11 23:07:29 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / s c a n
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2023-05-27 21:51:03 +02:00
async batchScan ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . isAdminOrUp ) {
Logger . warn ( ` Non-admin user " ${ req . user . username } " other than admin attempted to batch scan library items ` )
2023-05-27 21:51:03 +02:00
return res . sendStatus ( 403 )
}
if ( ! req . body . libraryItemIds ? . length ) {
return res . sendStatus ( 400 )
}
2023-09-04 18:50:55 +02:00
const libraryItems = await Database . libraryItemModel . findAll ( {
where : {
id : req . body . libraryItemIds
} ,
attributes : [ 'id' , 'libraryId' , 'isFile' ]
} )
2023-05-27 21:51:03 +02:00
if ( ! libraryItems ? . length ) {
return res . sendStatus ( 400 )
}
res . sendStatus ( 200 )
2023-09-02 17:46:47 +02:00
const libraryId = libraryItems [ 0 ] . libraryId
2023-05-27 21:51:03 +02:00
for ( const libraryItem of libraryItems ) {
if ( libraryItem . isFile ) {
Logger . warn ( ` [LibraryItemController] Re-scanning file library items not yet supported ` )
} else {
2023-09-04 18:50:55 +02:00
await LibraryItemScanner . scanLibraryItem ( libraryItem . id )
2023-05-27 21:51:03 +02:00
}
}
2023-08-20 20:16:53 +02:00
2023-09-02 17:46:47 +02:00
await Database . resetLibraryIssuesFilterData ( libraryId )
2023-05-27 21:51:03 +02:00
}
2024-08-11 23:07:29 +02:00
/ * *
* POST : / a p i / i t e m s / : i d / s c a n
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-18 17:51:55 +01:00
async scan ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . isAdminOrUp ) {
Logger . error ( ` [LibraryItemController] Non-admin user " ${ req . user . username } " attempted to scan library item ` )
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-09-04 18:50:55 +02:00
const result = await LibraryItemScanner . scanLibraryItem ( req . libraryItem . id )
2023-08-20 20:16:53 +02:00
await Database . resetLibraryIssuesFilterData ( req . libraryItem . libraryId )
2022-03-18 17:51:55 +01:00
res . json ( {
2024-06-22 23:42:13 +02:00
result : Object . keys ( ScanResult ) . find ( ( key ) => ScanResult [ key ] == result )
2022-03-18 17:51:55 +01:00
} )
}
2024-08-11 23:07:29 +02:00
/ * *
* GET : / a p i / i t e m s / : i d / m e t a d a t a - o b j e c t
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2024-07-06 23:00:48 +02:00
getMetadataObject ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . isAdminOrUp ) {
Logger . error ( ` [LibraryItemController] Non-admin user " ${ req . user . username } " attempted to get metadata object ` )
2022-09-25 22:56:06 +02:00
return res . sendStatus ( 403 )
}
if ( req . libraryItem . isMissing || ! req . libraryItem . hasAudioFiles || ! req . libraryItem . isBook ) {
Logger . error ( ` [LibraryItemController] Invalid library item ` )
return res . sendStatus ( 500 )
}
2024-07-06 23:00:48 +02:00
res . json ( this . audioMetadataManager . getMetadataObjectForApi ( req . libraryItem ) )
2022-09-25 22:56:06 +02:00
}
2024-08-11 23:07:29 +02:00
/ * *
* POST : / a p i / i t e m s / : i d / c h a p t e r s
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-05-11 00:03:41 +02:00
async updateMediaChapters ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . canUpdate ) {
Logger . error ( ` [LibraryItemController] User " ${ req . user . username } " attempted to update chapters with invalid permissions ` )
2022-05-11 00:03:41 +02:00
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 ) {
2023-07-05 01:14:44 +02:00
await Database . 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
} )
}
2023-06-25 23:16:11 +02:00
/ * *
2024-08-11 23:07:29 +02:00
* GET : / a p i / i t e m s / : i d / f f p r o b e / : f i l e i d
2023-06-25 23:16:11 +02:00
* FFProbe JSON result from audio file
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-06-25 23:16:11 +02:00
* /
async getFFprobeData ( req , res ) {
2024-08-11 23:07:29 +02:00
if ( ! req . user . isAdminOrUp ) {
Logger . error ( ` [LibraryItemController] Non-admin user " ${ req . user . username } " attempted to get ffprobe data ` )
2023-06-25 23:16:11 +02:00
return res . sendStatus ( 403 )
}
if ( req . libraryFile . fileType !== 'audio' ) {
Logger . error ( ` [LibraryItemController] Invalid filetype " ${ req . libraryFile . fileType } " for fileid " ${ req . params . fileid } ". Expected audio file ` )
return res . sendStatus ( 400 )
2022-10-02 22:24:32 +02:00
}
2023-06-25 23:16:11 +02:00
const audioFile = req . libraryItem . media . findFileWithInode ( req . params . fileid )
2022-10-02 22:24:32 +02:00
if ( ! audioFile ) {
2023-06-25 23:16:11 +02:00
Logger . error ( ` [LibraryItemController] Audio file not found with inode value ${ req . params . fileid } ` )
2022-10-02 22:24:32 +02:00
return res . sendStatus ( 404 )
}
2023-09-04 20:59:37 +02:00
const ffprobeData = await AudioFileScanner . probeAudioFile ( audioFile )
2023-06-25 23:16:11 +02:00
res . json ( ffprobeData )
2022-10-02 22:24:32 +02:00
}
2023-05-28 19:34:22 +02:00
/ * *
* GET api / items / : id / file / : fileid
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-05-28 19:34:22 +02:00
* /
async getLibraryFile ( req , res ) {
const libraryFile = req . libraryFile
if ( global . XAccel ) {
2023-09-18 22:08:19 +02:00
const encodedURI = encodeUriPath ( global . XAccel + libraryFile . metadata . path )
Logger . debug ( ` Use X-Accel to serve static file ${ encodedURI } ` )
return res . status ( 204 ) . header ( { 'X-Accel-Redirect' : encodedURI } ) . 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
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-05-28 19:34:22 +02:00
* /
async deleteLibraryFile ( req , res ) {
const libraryFile = req . libraryFile
2024-08-11 23:07:29 +02:00
Logger . info ( ` [LibraryItemController] User " ${ req . user . username } " requested file delete at " ${ libraryFile . metadata . path } " ` )
2023-05-28 19:34:22 +02:00
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 ( )
2023-07-05 01:14:44 +02:00
await Database . updateLibraryItem ( req . libraryItem )
2023-04-14 01:03:39 +02:00
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
2024-08-11 23:07:29 +02:00
*
* @ param { RequestWithUser } req
* @ param { Response } res
2023-05-28 19:34:22 +02:00
* /
async downloadLibraryFile ( req , res ) {
const libraryFile = req . libraryFile
2024-08-21 04:00:29 +02:00
const ua = uaParserJs ( req . headers [ 'user-agent' ] )
2023-05-28 19:34:22 +02:00
2024-08-11 23:07:29 +02:00
if ( ! req . user . canDownload ) {
Logger . error ( ` [LibraryItemController] User " ${ req . user . username } " without download permission attempted to download file " ${ libraryFile . metadata . path } " ` )
2023-05-28 19:34:22 +02:00
return res . sendStatus ( 403 )
}
2024-08-11 23:07:29 +02:00
Logger . info ( ` [LibraryItemController] User " ${ req . user . username } " requested download for item " ${ req . libraryItem . media . metadata . title } " file at " ${ libraryFile . metadata . path } " ` )
2023-05-28 19:34:22 +02:00
if ( global . XAccel ) {
2023-09-18 22:08:19 +02:00
const encodedURI = encodeUriPath ( global . XAccel + libraryFile . metadata . path )
Logger . debug ( ` Use X-Accel to serve static file ${ encodedURI } ` )
return res . status ( 204 ) . header ( { 'X-Accel-Redirect' : encodedURI } ) . send ( )
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
2024-08-21 04:00:29 +02:00
let audioMimeType = getAudioMimeTypeFromExtname ( Path . extname ( libraryFile . metadata . path ) )
2023-05-28 19:34:22 +02:00
if ( audioMimeType ) {
2024-08-21 04:00:29 +02:00
// Work-around for Apple devices mishandling Content-Type on mobile browsers:
// https://github.com/advplyr/audiobookshelf/issues/3310
// 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 ) {
2024-08-31 20:27:48 +02:00
audioMimeType = 'audio/m4b'
2024-08-21 04:00:29 +02:00
}
2023-05-28 19:34:22 +02:00
res . setHeader ( 'Content-Type' , audioMimeType )
}
2024-10-28 07:03:31 +01:00
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 )
this . # handleDownloadError ( error , res )
}
2023-05-28 19:34:22 +02:00
}
/ * *
2023-06-10 19:46:57 +02:00
* GET api / items / : id / ebook / : fileid ?
* fileid is the inode value stored in LibraryFile . ino or EBookFile . ino
* fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-05-28 19:34:22 +02:00
* /
2023-05-28 17:47:28 +02:00
async getEBookFile ( req , res ) {
2023-06-10 19:46:57 +02:00
let ebookFile = null
if ( req . params . fileid ) {
2024-06-22 23:42:13 +02:00
ebookFile = req . libraryItem . libraryFiles . find ( ( lf ) => lf . ino === req . params . fileid )
2023-06-10 19:46:57 +02:00
if ( ! ebookFile ? . isEBookFile ) {
Logger . error ( ` [LibraryItemController] Invalid ebook file id " ${ req . params . fileid } " ` )
return res . status ( 400 ) . send ( 'Invalid ebook file id' )
}
} else {
ebookFile = req . libraryItem . media . ebookFile
}
2023-05-28 17:47:28 +02:00
if ( ! ebookFile ) {
Logger . error ( ` [LibraryItemController] No ebookFile for library item " ${ req . libraryItem . media . metadata . title } " ` )
return res . sendStatus ( 404 )
}
const ebookFilePath = ebookFile . metadata . path
2024-08-04 00:09:17 +02:00
2024-08-11 23:07:29 +02:00
Logger . info ( ` [LibraryItemController] User " ${ req . user . username } " requested download for item " ${ req . libraryItem . media . metadata . title } " ebook at " ${ ebookFilePath } " ` )
2023-05-28 19:34:22 +02:00
if ( global . XAccel ) {
2023-09-18 22:08:19 +02:00
const encodedURI = encodeUriPath ( global . XAccel + ebookFilePath )
Logger . debug ( ` Use X-Accel to serve static file ${ encodedURI } ` )
return res . status ( 204 ) . header ( { 'X-Accel-Redirect' : encodedURI } ) . send ( )
2023-05-28 19:34:22 +02:00
}
2024-10-28 07:03:31 +01:00
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 )
this . # handleDownloadError ( error , res )
}
2023-05-28 17:47:28 +02:00
}
2023-06-10 19:46:57 +02:00
/ * *
* PATCH api / items / : id / ebook / : fileid / status
* toggle the status of an ebook file .
* if an ebook file is the primary ebook , then it will be changed to supplementary
* if an ebook file is supplementary , then it will be changed to primary
2024-06-22 23:42:13 +02:00
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
2023-06-10 19:46:57 +02:00
* /
async updateEbookFileStatus ( req , res ) {
2024-06-22 23:42:13 +02:00
const ebookLibraryFile = req . libraryItem . libraryFiles . find ( ( lf ) => lf . ino === req . params . fileid )
2023-06-10 19:46:57 +02:00
if ( ! ebookLibraryFile ? . isEBookFile ) {
Logger . error ( ` [LibraryItemController] Invalid ebook file id " ${ req . params . fileid } " ` )
return res . status ( 400 ) . send ( 'Invalid ebook file id' )
}
if ( ebookLibraryFile . isSupplementary ) {
Logger . info ( ` [LibraryItemController] Updating ebook file " ${ ebookLibraryFile . metadata . filename } " to primary ` )
req . libraryItem . setPrimaryEbook ( ebookLibraryFile )
} else {
Logger . info ( ` [LibraryItemController] Updating ebook file " ${ ebookLibraryFile . metadata . filename } " to supplementary ` )
ebookLibraryFile . isSupplementary = true
req . libraryItem . setPrimaryEbook ( null )
}
req . libraryItem . updatedAt = Date . now ( )
2023-07-05 01:14:44 +02:00
await Database . updateLibraryItem ( req . libraryItem )
2023-06-10 19:46:57 +02:00
SocketAuthority . emitter ( 'item_updated' , req . libraryItem . toJSONExpanded ( ) )
res . sendStatus ( 200 )
}
2024-08-04 00:09:17 +02:00
/ * *
*
2024-08-11 23:07:29 +02:00
* @ param { RequestWithUser } req
* @ param { Response } res
* @ param { NextFunction } next
2024-08-04 00:09:17 +02:00
* /
2023-08-06 22:06:45 +02:00
async middleware ( req , res , next ) {
2023-08-20 20:34:03 +02:00
req . libraryItem = await Database . libraryItemModel . getOldById ( req . params . id )
2023-05-28 19:34:22 +02:00
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
2024-08-11 23:07:29 +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 ) {
2024-06-22 23:42:13 +02:00
req . libraryFile = req . libraryItem . libraryFiles . find ( ( lf ) => lf . ino === req . params . fileid )
2023-05-28 19:34:22 +02:00
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
2024-08-11 23:07:29 +02:00
} else if ( req . method == 'DELETE' && ! req . user . canDelete ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to delete without permission ` )
2022-03-13 00:45:32 +01:00
return res . sendStatus ( 403 )
2024-08-11 23:07:29 +02:00
} else if ( ( req . method == 'PATCH' || req . method == 'POST' ) && ! req . user . canUpdate ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to update without permission ` )
2022-03-13 00:45:32 +01:00
return res . sendStatus ( 403 )
}
2022-03-11 01:45:02 +01:00
next ( )
}
}
2024-06-22 23:42:13 +02:00
module . exports = new LibraryItemController ( )