2024-08-11 15:15:34 -05:00
const { Request , Response , NextFunction } = require ( 'express' )
2023-05-28 10:47:28 -05:00
const Path = require ( 'path' )
2022-12-04 16:23:15 -06:00
const fs = require ( '../libs/fsExtra' )
2024-08-20 19:00:29 -07:00
const uaParserJs = require ( '../libs/uaParser' )
2022-03-10 18:45:02 -06:00
const Logger = require ( '../Logger' )
2022-11-24 15:53:58 -06:00
const SocketAuthority = require ( '../SocketAuthority' )
2023-07-04 18:14:44 -05:00
const Database = require ( '../Database' )
2022-11-24 15:53:58 -06:00
2023-04-09 17:05:35 -05:00
const zipHelpers = require ( '../utils/zipHelpers' )
2023-07-04 18:14:44 -05:00
const { reqSupportsWebp } = require ( '../utils/index' )
2024-08-20 19:00:29 -07:00
const { ScanResult , AudioMimeType } = require ( '../utils/constants' )
2026-02-13 14:15:18 +02:00
const { getAudioMimeTypeFromExtname , encodeUriPath , sanitizeFilename } = require ( '../utils/fileUtils' )
2023-09-03 17:51:58 -05:00
const LibraryItemScanner = require ( '../scanner/LibraryItemScanner' )
2023-09-04 13:59:37 -05:00
const AudioFileScanner = require ( '../scanner/AudioFileScanner' )
2023-09-06 17:48:50 -05:00
const Scanner = require ( '../scanner/Scanner' )
2026-02-06 22:21:22 +02:00
const Watcher = require ( '../Watcher' )
const libraryItemsBookFilters = require ( '../utils/queries/libraryItemsBookFilters' )
const libraryItemsPodcastFilters = require ( '../utils/queries/libraryItemsPodcastFilters' )
2024-12-15 12:37:01 -06:00
const RssFeedManager = require ( '../managers/RssFeedManager' )
2023-09-06 17:48:50 -05:00
const CacheManager = require ( '../managers/CacheManager' )
const CoverManager = require ( '../managers/CoverManager' )
2024-06-22 16:42:13 -05:00
const ShareManager = require ( '../managers/ShareManager' )
2022-03-10 18:45:02 -06:00
2024-08-11 15:15:34 -05:00
/ * *
2024-08-11 17:01:25 -05:00
* @ typedef RequestUserObject
2024-08-11 16:07:29 -05:00
* @ property { import ( '../models/User' ) } user
2024-08-11 15:15:34 -05:00
*
2024-08-11 17:01:25 -05:00
* @ typedef { Request & RequestUserObject } RequestWithUser
2025-01-02 12:49:58 -06:00
*
* @ typedef RequestEntityObject
* @ property { import ( '../models/LibraryItem' ) } libraryItem
*
* @ typedef { RequestWithUser & RequestEntityObject } LibraryItemControllerRequest
*
* @ typedef RequestLibraryFileObject
2025-01-02 15:42:52 -06:00
* @ property { import ( '../objects/files/LibraryFile' ) } libraryFile
2025-01-02 12:49:58 -06:00
*
* @ typedef { RequestWithUser & RequestEntityObject & RequestLibraryFileObject } LibraryItemControllerRequestWithFile
2024-08-11 15:15:34 -05:00
* /
2026-02-06 15:01:07 +02:00
/ * *
* Internal helper to move a single library item to a target library / folder
*
* @ param { import ( '../models/LibraryItem' ) } libraryItem
* @ param { import ( '../models/Library' ) } targetLibrary
* @ param { import ( '../models/LibraryFolder' ) } targetFolder
* /
2026-02-17 15:48:28 +02:00
async function handleMoveLibraryItem ( libraryItem , targetLibrary , targetFolder , newItemFolderName = null , forceMerge = false ) {
2026-02-06 15:01:07 +02:00
const oldPath = libraryItem . path
const oldLibraryId = libraryItem . libraryId
2026-02-17 10:56:29 +02:00
const oldIsFile = libraryItem . isFile
2026-02-06 15:01:07 +02:00
// Calculate new paths
2026-02-13 14:15:18 +02:00
const itemFolderName = newItemFolderName || Path . basename ( libraryItem . path )
2026-02-06 15:01:07 +02:00
const newPath = Path . join ( targetFolder . path , itemFolderName )
const newRelPath = itemFolderName
// Check if destination already exists
const destinationExists = await fs . pathExists ( newPath )
2026-02-15 21:23:34 +02:00
const isSamePath = oldPath === newPath
2026-02-17 15:48:28 +02:00
if ( destinationExists && ! isSamePath && ! forceMerge ) {
const error = new Error ( ` Destination already exists: ${ newPath } ` )
error . code = 'EEXIST'
error . path = newPath
throw error
2026-02-06 15:01:07 +02:00
}
try {
2026-02-06 22:21:22 +02:00
Watcher . addIgnoreDir ( oldPath )
2026-02-15 21:23:34 +02:00
if ( ! isSamePath ) Watcher . addIgnoreDir ( newPath )
2026-02-06 22:21:22 +02:00
const oldRelPath = libraryItem . relPath
2026-02-15 21:23:34 +02:00
if ( ! isSamePath ) {
2026-02-17 15:48:28 +02:00
Logger . info ( ` [LibraryItemController] Moving item " ${ libraryItem . media . title } " from " ${ oldPath } " to " ${ newPath } " (forceMerge: ${ forceMerge } ) ` )
2026-02-17 10:56:29 +02:00
if ( libraryItem . isFile && newItemFolderName ) {
// Handle single file consolidation: create folder and move file inside
await fs . ensureDir ( newPath )
2026-02-17 15:48:28 +02:00
let destPath = Path . join ( newPath , Path . basename ( oldPath ) )
if ( await fs . pathExists ( destPath ) ) {
const filename = Path . basename ( oldPath )
const name = Path . parse ( filename ) . name
const ext = Path . parse ( filename ) . ext
destPath = Path . join ( newPath , ` ${ name } _ ${ Date . now ( ) } ${ ext } ` )
}
2026-02-17 10:56:29 +02:00
await fs . move ( oldPath , destPath )
libraryItem . isFile = false
2026-02-17 15:48:28 +02:00
} else if ( forceMerge && destinationExists ) {
// Move all files from this directory to target directory
const files = await fs . readdir ( oldPath )
for ( const file of files ) {
const srcFile = Path . join ( oldPath , file )
let destFile = Path . join ( newPath , file )
if ( await fs . pathExists ( destFile ) ) {
const name = Path . parse ( file ) . name
const ext = Path . parse ( file ) . ext
destFile = Path . join ( newPath , ` ${ name } _ ${ Date . now ( ) } ${ ext } ` )
}
await fs . move ( srcFile , destFile )
}
// Remove the now empty directory
await fs . remove ( oldPath )
2026-02-17 10:56:29 +02:00
} else {
await fs . move ( oldPath , newPath )
}
2026-02-15 21:23:34 +02:00
}
2026-02-06 15:01:07 +02:00
2026-02-06 22:35:03 +02:00
// Update database within a transaction
const transaction = await Database . sequelize . transaction ( )
try {
// Update library item in database
libraryItem . libraryId = targetLibrary . id
libraryItem . libraryFolderId = targetFolder . id
libraryItem . path = newPath
libraryItem . relPath = newRelPath
libraryItem . isMissing = false
libraryItem . isInvalid = false
2026-02-15 15:46:46 +02:00
libraryItem . isNotConsolidated = libraryItem . checkIsNotConsolidated ( )
2026-02-06 22:35:03 +02:00
libraryItem . changed ( 'updatedAt' , true )
2026-02-06 22:25:44 +02:00
await libraryItem . save ( { transaction } )
2026-02-06 15:01:07 +02:00
2026-02-06 22:35:03 +02:00
// Update library files paths
if ( libraryItem . libraryFiles ? . length ) {
libraryItem . libraryFiles = libraryItem . libraryFiles . map ( ( lf ) => {
if ( lf . metadata ? . path ) {
2026-02-17 10:56:29 +02:00
if ( oldIsFile && newItemFolderName ) {
lf . metadata . path = Path . join ( newPath , Path . basename ( lf . metadata . path ) )
} else {
lf . metadata . path = lf . metadata . path . replace ( oldPath , newPath )
}
2026-02-06 15:01:07 +02:00
}
2026-02-06 22:35:03 +02:00
if ( lf . metadata ? . relPath ) {
2026-02-17 10:56:29 +02:00
if ( oldIsFile && newItemFolderName ) {
lf . metadata . relPath = Path . join ( newRelPath , Path . basename ( lf . metadata . relPath ) )
} else {
lf . metadata . relPath = lf . metadata . relPath . replace ( oldRelPath , newRelPath )
}
2026-02-06 22:21:22 +02:00
}
2026-02-06 22:35:03 +02:00
return lf
2026-02-06 15:01:07 +02:00
} )
2026-02-06 22:35:03 +02:00
libraryItem . changed ( 'libraryFiles' , true )
await libraryItem . save ( { transaction } )
2026-02-06 15:01:07 +02:00
}
2026-02-06 22:35:03 +02:00
// Update media file paths (audioFiles, ebookFile for books; podcastEpisodes for podcasts)
if ( libraryItem . isBook ) {
// Update audioFiles paths
if ( libraryItem . media . audioFiles ? . length ) {
libraryItem . media . audioFiles = libraryItem . media . audioFiles . map ( ( af ) => {
if ( af . metadata ? . path ) {
2026-02-17 10:56:29 +02:00
if ( oldIsFile && newItemFolderName ) {
af . metadata . path = Path . join ( newPath , Path . basename ( af . metadata . path ) )
} else {
af . metadata . path = af . metadata . path . replace ( oldPath , newPath )
}
2026-02-06 22:35:03 +02:00
}
if ( af . metadata ? . relPath ) {
2026-02-17 10:56:29 +02:00
if ( oldIsFile && newItemFolderName ) {
af . metadata . relPath = Path . join ( newRelPath , Path . basename ( af . metadata . relPath ) )
} else {
af . metadata . relPath = af . metadata . relPath . replace ( oldRelPath , newRelPath )
}
2026-02-06 22:35:03 +02:00
}
return af
} )
libraryItem . media . changed ( 'audioFiles' , true )
2026-02-06 22:21:22 +02:00
}
2026-02-06 22:35:03 +02:00
// Update ebookFile path
if ( libraryItem . media . ebookFile ? . metadata ? . path ) {
2026-02-17 10:56:29 +02:00
if ( oldIsFile && newItemFolderName ) {
libraryItem . media . ebookFile . metadata . path = Path . join ( newPath , Path . basename ( libraryItem . media . ebookFile . metadata . path ) )
} else {
libraryItem . media . ebookFile . metadata . path = libraryItem . media . ebookFile . metadata . path . replace ( oldPath , newPath )
}
2026-02-06 22:35:03 +02:00
if ( libraryItem . media . ebookFile . metadata ? . relPath ) {
2026-02-17 10:56:29 +02:00
if ( oldIsFile && newItemFolderName ) {
libraryItem . media . ebookFile . metadata . relPath = Path . join ( newRelPath , Path . basename ( libraryItem . media . ebookFile . metadata . relPath ) )
} else {
libraryItem . media . ebookFile . metadata . relPath = libraryItem . media . ebookFile . metadata . relPath . replace ( oldRelPath , newRelPath )
}
2026-02-06 22:35:03 +02:00
}
libraryItem . media . changed ( 'ebookFile' , true )
}
// Update coverPath
if ( libraryItem . media . coverPath ) {
2026-02-17 10:56:29 +02:00
if ( oldIsFile && newItemFolderName ) {
libraryItem . media . coverPath = Path . join ( newPath , Path . basename ( libraryItem . media . coverPath ) )
} else {
libraryItem . media . coverPath = libraryItem . media . coverPath . replace ( oldPath , newPath )
}
2026-02-06 22:21:22 +02:00
}
2026-02-06 22:35:03 +02:00
await libraryItem . media . save ( { transaction } )
2026-02-17 10:56:29 +02:00
}
else if ( libraryItem . isPodcast ) {
2026-02-06 22:35:03 +02:00
// Update coverPath
if ( libraryItem . media . coverPath ) {
libraryItem . media . coverPath = libraryItem . media . coverPath . replace ( oldPath , newPath )
2026-02-06 22:21:22 +02:00
}
2026-02-06 22:35:03 +02:00
await libraryItem . media . save ( { transaction } )
// Update podcast episode audio file paths
for ( const episode of libraryItem . media . podcastEpisodes || [ ] ) {
let episodeUpdated = false
if ( episode . audioFile ? . metadata ? . path ) {
episode . audioFile . metadata . path = episode . audioFile . metadata . path . replace ( oldPath , newPath )
episodeUpdated = true
}
if ( episode . audioFile ? . metadata ? . relPath ) {
episode . audioFile . metadata . relPath = episode . audioFile . metadata . relPath . replace ( oldRelPath , newRelPath )
episodeUpdated = true
}
if ( episodeUpdated ) {
episode . changed ( 'audioFile' , true )
await episode . save ( { transaction } )
}
2026-02-06 15:01:07 +02:00
}
}
2026-02-06 22:35:03 +02:00
// Handle Series and Authors when moving a book
if ( libraryItem . isBook ) {
// Handle Series
const bookSeries = await Database . bookSeriesModel . findAll ( {
where : { bookId : libraryItem . media . id } ,
transaction
} )
for ( const bs of bookSeries ) {
const sourceSeries = await Database . seriesModel . findByPk ( bs . seriesId , { transaction } )
if ( sourceSeries ) {
const targetSeries = await Database . seriesModel . findOrCreateByNameAndLibrary ( sourceSeries . name , targetLibrary . id , transaction )
// If target series doesn't have a description but source does, copy it
if ( ! targetSeries . description && sourceSeries . description ) {
targetSeries . description = sourceSeries . description
await targetSeries . save ( { transaction } )
}
2026-02-06 15:01:07 +02:00
2026-02-06 22:35:03 +02:00
// Update link
bs . seriesId = targetSeries . id
await bs . save ( { transaction } )
2026-02-06 15:01:07 +02:00
2026-02-06 22:35:03 +02:00
// Check if source series is now empty
const sourceSeriesBooksCount = await Database . bookSeriesModel . count ( { where : { seriesId : sourceSeries . id } , transaction } )
if ( sourceSeriesBooksCount === 0 ) {
Logger . info ( ` [LibraryItemController] Source series " ${ sourceSeries . name } " in library ${ oldLibraryId } is now empty. Deleting. ` )
await sourceSeries . destroy ( { transaction } )
Database . removeSeriesFromFilterData ( oldLibraryId , sourceSeries . id )
2026-02-06 22:25:44 +02:00
2026-02-06 22:35:03 +02:00
SocketAuthority . emitter ( 'series_removed' , { id : sourceSeries . id , libraryId : oldLibraryId } )
}
2026-02-06 15:01:07 +02:00
}
}
2026-02-06 22:35:03 +02:00
// Handle Authors
const bookAuthors = await Database . bookAuthorModel . findAll ( {
where : { bookId : libraryItem . media . id } ,
transaction
} )
for ( const ba of bookAuthors ) {
const sourceAuthor = await Database . authorModel . findByPk ( ba . authorId , { transaction } )
if ( sourceAuthor ) {
const targetAuthor = await Database . authorModel . findOrCreateByNameAndLibrary ( sourceAuthor . name , targetLibrary . id , transaction )
// Copy description and ASIN if target doesn't have them
let targetAuthorUpdated = false
if ( ! targetAuthor . description && sourceAuthor . description ) {
targetAuthor . description = sourceAuthor . description
targetAuthorUpdated = true
}
if ( ! targetAuthor . asin && sourceAuthor . asin ) {
targetAuthor . asin = sourceAuthor . asin
2026-02-06 15:01:07 +02:00
targetAuthorUpdated = true
}
2026-02-06 22:35:03 +02:00
// Copy image if target doesn't have one
if ( ! targetAuthor . imagePath && sourceAuthor . imagePath && ( await fs . pathExists ( sourceAuthor . imagePath ) ) ) {
const ext = Path . extname ( sourceAuthor . imagePath )
const newImagePath = Path . posix . join ( Path . join ( global . MetadataPath , 'authors' ) , targetAuthor . id + ext )
try {
await fs . copy ( sourceAuthor . imagePath , newImagePath )
targetAuthor . imagePath = newImagePath
targetAuthorUpdated = true
} catch ( err ) {
Logger . error ( ` [LibraryItemController] Failed to copy author image ` , err )
}
}
2026-02-06 15:01:07 +02:00
2026-02-06 22:35:03 +02:00
if ( targetAuthorUpdated ) await targetAuthor . save ( { transaction } )
2026-02-06 15:01:07 +02:00
2026-02-06 22:35:03 +02:00
// Update link
ba . authorId = targetAuthor . id
await ba . save ( { transaction } )
// Check if source author is now empty
const sourceAuthorBooksCount = await Database . bookAuthorModel . getCountForAuthor ( sourceAuthor . id , transaction )
if ( sourceAuthorBooksCount === 0 ) {
Logger . info ( ` [LibraryItemController] Source author " ${ sourceAuthor . name } " in library ${ oldLibraryId } is now empty. Deleting. ` )
if ( sourceAuthor . imagePath ) {
await fs . remove ( sourceAuthor . imagePath ) . catch ( ( err ) => Logger . error ( ` [LibraryItemController] Failed to remove source author image ` , err ) )
}
await sourceAuthor . destroy ( { transaction } )
Database . removeAuthorFromFilterData ( oldLibraryId , sourceAuthor . id )
2026-02-06 22:25:44 +02:00
2026-02-06 22:35:03 +02:00
SocketAuthority . emitter ( 'author_removed' , { id : sourceAuthor . id , libraryId : oldLibraryId } )
}
2026-02-06 15:01:07 +02:00
}
}
}
2026-02-06 22:35:03 +02:00
await transaction . commit ( )
} catch ( dbError ) {
if ( transaction ) await transaction . rollback ( )
throw dbError
2026-02-06 15:01:07 +02:00
}
// Emit socket events for UI updates
2026-02-14 22:03:02 +02:00
if ( oldLibraryId !== targetLibrary . id ) {
SocketAuthority . emitter ( 'item_removed' , {
id : libraryItem . id ,
libraryId : oldLibraryId
} )
SocketAuthority . libraryItemEmitter ( 'item_added' , libraryItem )
} else {
SocketAuthority . libraryItemEmitter ( 'item_updated' , libraryItem )
}
2026-02-06 15:01:07 +02:00
Logger . info ( ` [LibraryItemController] Successfully moved item " ${ libraryItem . media . title } " to library " ${ targetLibrary . name } " ` )
} catch ( error ) {
Logger . error ( ` [LibraryItemController] Failed to move item " ${ libraryItem . media . title } " ` , error )
// Attempt to rollback file move if database update failed
if ( await fs . pathExists ( newPath ) ) {
try {
await fs . move ( newPath , oldPath )
Logger . info ( ` [LibraryItemController] Rolled back file move for item " ${ libraryItem . media . title } " ` )
} catch ( rollbackError ) {
Logger . error ( ` [LibraryItemController] Failed to rollback file move ` , rollbackError )
}
}
throw error
2026-02-06 22:21:22 +02:00
} finally {
Watcher . removeIgnoreDir ( oldPath )
2026-02-15 21:23:34 +02:00
if ( typeof isSamePath !== 'undefined' && ! isSamePath ) Watcher . removeIgnoreDir ( newPath )
2026-02-06 15:01:07 +02:00
}
}
2022-03-10 18:45:02 -06:00
class LibraryItemController {
2024-06-22 16:42:13 -05:00
constructor ( ) { }
2022-03-10 18:45:02 -06:00
2023-09-03 10:04:14 -05:00
/ * *
* GET : / a p i / i t e m s / : i d
* Optional query params :
2024-07-01 17:26:13 -05:00
* ? include = progress , rssfeed , downloads , share
2023-09-03 10:04:14 -05:00
* ? expanded = 1
2024-06-22 16:42:13 -05:00
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-09-03 10:04:14 -05:00
* /
2023-07-17 16:48:46 -05:00
async findOne ( req , res ) {
2022-03-21 05:08:33 -05:00
const includeEntities = ( req . query . include || '' ) . split ( ',' )
if ( req . query . expanded == 1 ) {
2025-01-02 12:49:58 -06:00
const item = req . libraryItem . toOldJSONExpanded ( )
2022-03-21 05:08:33 -05:00
2022-04-25 19:03:26 -05:00
// Include users media progress
if ( includeEntities . includes ( 'progress' ) ) {
2025-01-02 12:49:58 -06:00
const episodeId = req . query . episode || null
2024-08-11 16:07:29 -05:00
item . userMediaProgress = req . user . getOldMediaProgress ( item . id , episodeId )
2022-04-25 19:03:26 -05:00
}
2022-05-02 16:42:30 -05:00
if ( includeEntities . includes ( 'rssfeed' ) ) {
2024-12-15 12:37:01 -06:00
const feedData = await RssFeedManager . findFeedForEntityId ( item . id )
2024-12-15 17:54:36 -06:00
item . rssFeed = feedData ? . toOldJSONMinified ( ) || null
2022-05-02 16:42:30 -05:00
}
2024-08-11 16:07:29 -05:00
if ( item . mediaType === 'book' && req . user . isAdminOrUp && includeEntities . includes ( 'share' ) ) {
2024-06-22 16:42:13 -05:00
item . mediaItemShare = ShareManager . findByMediaItemId ( item . media . id )
}
2023-09-03 10:04:14 -05:00
if ( item . mediaType === 'podcast' && includeEntities . includes ( 'downloads' ) ) {
2023-03-05 10:35:34 -06:00
const downloadsInQueue = this . podcastManager . getEpisodeDownloadsInQueue ( req . libraryItem . id )
2024-06-22 16:42:13 -05:00
item . episodeDownloadsQueued = downloadsInQueue . map ( ( d ) => d . toJSONForClient ( ) )
2023-03-05 10:35:34 -06:00
if ( this . podcastManager . currentDownload ? . libraryItemId === req . libraryItem . id ) {
item . episodesDownloading = [ this . podcastManager . currentDownload . toJSONForClient ( ) ]
}
2022-03-21 05:08:33 -05:00
}
2023-12-31 14:51:01 -06:00
2022-03-21 05:08:33 -05:00
return res . json ( item )
}
2025-01-02 12:49:58 -06:00
res . json ( req . libraryItem . toOldJSON ( ) )
2022-03-10 18:45:02 -06:00
}
2024-08-03 17:09:17 -05: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-12-01 09:51:26 -06:00
* @ this { import ( '../routers/ApiRouter' ) }
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2024-08-03 17:09:17 -05:00
* /
2022-03-12 17:45:32 -06:00
async delete ( req , res ) {
2023-04-14 16:44:41 -05:00
const hardDelete = req . query . hard == 1 // Delete from file system
const libraryItemPath = req . libraryItem . path
2024-08-03 17:09:17 -05:00
2024-12-01 09:51:26 -06:00
const mediaItemIds = [ ]
const authorIds = [ ]
const seriesIds = [ ]
if ( req . libraryItem . isPodcast ) {
2025-01-02 12:49:58 -06:00
mediaItemIds . push ( ... req . libraryItem . media . podcastEpisodes . map ( ( ep ) => ep . id ) )
2024-12-01 09:51:26 -06:00
} else {
mediaItemIds . push ( req . libraryItem . media . id )
2025-01-02 12:49:58 -06:00
if ( req . libraryItem . media . authors ? . length ) {
authorIds . push ( ... req . libraryItem . media . authors . map ( ( au ) => au . id ) )
2024-12-01 09:51:26 -06:00
}
2025-01-02 12:49:58 -06:00
if ( req . libraryItem . media . series ? . length ) {
seriesIds . push ( ... req . libraryItem . media . series . map ( ( se ) => se . id ) )
2024-12-01 09:51:26 -06:00
}
}
await this . handleDeleteLibraryItem ( req . libraryItem . id , mediaItemIds )
2023-04-14 16:44:41 -05: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 )
} )
}
2024-12-01 09:51:26 -06:00
if ( authorIds . length ) {
await this . checkRemoveAuthorsWithNoBooks ( authorIds )
}
if ( seriesIds . length ) {
await this . checkRemoveEmptySeries ( seriesIds )
}
2023-08-20 13:16:53 -05:00
await Database . resetLibraryIssuesFilterData ( req . libraryItem . libraryId )
2022-03-12 17:45:32 -06:00
res . sendStatus ( 200 )
}
2024-10-29 21:42:44 +02:00
static handleDownloadError ( error , res ) {
2024-10-28 08:03:31 +02:00
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-10 17:51:52 -05: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 16:42:13 -05:00
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-10-10 17:51:52 -05:00
* /
2024-10-28 08:03:31 +02:00
async download ( req , res ) {
2024-08-11 16:07:29 -05:00
if ( ! req . user . canDownload ) {
Logger . warn ( ` User " ${ req . user . username } " attempted to download without permission ` )
2023-04-09 17:05:35 -05:00
return res . sendStatus ( 403 )
}
2024-08-09 16:48:21 -05:00
const libraryItemPath = req . libraryItem . path
2025-01-02 12:49:58 -06:00
const itemTitle = req . libraryItem . media . title
2023-04-09 17:05:35 -05:00
2024-10-28 08:03:31 +02: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-10 17:51:52 -05:00
}
2024-10-28 08:03:31 +02:00
Logger . info ( ` [LibraryItemController] Downloaded item " ${ itemTitle } " at " ${ libraryItemPath } " ` )
} catch ( error ) {
Logger . error ( ` [LibraryItemController] Download failed for item " ${ itemTitle } " at " ${ libraryItemPath } " ` , error )
2024-10-29 21:42:44 +02:00
LibraryItemController . handleDownloadError ( error , res )
2023-10-10 17:51:52 -05:00
}
2023-04-09 17:05:35 -05:00
}
2024-04-20 11:34:21 -05:00
/ * *
* PATCH : /items/ : id / media
* Update media for a library item . Will create new authors & series when necessary
2024-06-22 16:42:13 -05:00
*
2024-08-31 13:27:48 -05:00
* @ this { import ( '../routers/ApiRouter' ) }
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2024-04-20 11:34:21 -05:00
* /
2022-03-11 19:46:32 -06:00
async updateMedia ( req , res ) {
2022-12-28 18:08:03 -06:00
const mediaPayload = req . body
2023-07-17 08:09:08 -05:00
2024-02-01 12:03:12 +02:00
if ( mediaPayload . url ) {
await LibraryItemController . prototype . uploadCover . bind ( this ) ( req , res , false )
2024-04-20 11:34:21 -05:00
if ( res . writableEnded || res . headersSent ) return
2024-02-01 12:03:12 +02:00
}
2022-08-19 18:41:58 -05:00
// Podcast specific
2022-12-28 18:08:03 -06:00
let isPodcastAutoDownloadUpdated = false
2025-01-02 12:49:58 -06:00
if ( req . libraryItem . isPodcast ) {
if ( mediaPayload . autoDownloadEpisodes !== undefined && req . libraryItem . media . autoDownloadEpisodes !== mediaPayload . autoDownloadEpisodes ) {
2022-08-19 18:41:58 -05:00
isPodcastAutoDownloadUpdated = true
2025-01-02 12:49:58 -06:00
} else if ( mediaPayload . autoDownloadSchedule !== undefined && req . libraryItem . media . autoDownloadSchedule !== mediaPayload . autoDownloadSchedule ) {
2022-08-19 18:41:58 -05:00
isPodcastAutoDownloadUpdated = true
}
}
2025-01-05 12:05:01 -06:00
let hasUpdates = ( await req . libraryItem . media . updateFromRequest ( mediaPayload ) ) || mediaPayload . url
if ( req . libraryItem . isBook && Array . isArray ( mediaPayload . metadata ? . series ) ) {
const seriesUpdateData = await req . libraryItem . media . updateSeriesFromRequest ( mediaPayload . metadata . series , req . libraryItem . libraryId )
if ( seriesUpdateData ? . seriesRemoved . length ) {
// Check remove empty series
Logger . debug ( ` [LibraryItemController] Series were removed from book. Check if series are now empty. ` )
await this . checkRemoveEmptySeries ( seriesUpdateData . seriesRemoved . map ( ( se ) => se . id ) )
}
if ( seriesUpdateData ? . seriesAdded . length ) {
// Add series to filter data
seriesUpdateData . seriesAdded . forEach ( ( se ) => {
Database . addSeriesToFilterData ( req . libraryItem . libraryId , se . name , se . id )
} )
}
if ( seriesUpdateData ? . hasUpdates ) {
hasUpdates = true
}
2022-12-31 16:58:19 -06:00
}
2025-01-05 12:05:01 -06:00
if ( req . libraryItem . isBook && Array . isArray ( mediaPayload . metadata ? . authors ) ) {
const authorNames = mediaPayload . metadata . authors . map ( ( au ) => ( typeof au . name === 'string' ? au . name . trim ( ) : null ) ) . filter ( ( au ) => au )
const authorUpdateData = await req . libraryItem . media . updateAuthorsFromRequest ( authorNames , req . libraryItem . libraryId )
if ( authorUpdateData ? . authorsRemoved . length ) {
// Check remove empty authors
Logger . debug ( ` [LibraryItemController] Authors were removed from book. Check if authors are now empty. ` )
await this . checkRemoveAuthorsWithNoBooks ( authorUpdateData . authorsRemoved . map ( ( au ) => au . id ) )
hasUpdates = true
}
if ( authorUpdateData ? . authorsAdded . length ) {
// Add authors to filter data
authorUpdateData . authorsAdded . forEach ( ( au ) => {
Database . addAuthorToFilterData ( req . libraryItem . libraryId , au . name , au . id )
} )
hasUpdates = true
}
2024-08-31 13:27:48 -05:00
}
2022-03-11 19:46:32 -06:00
if ( hasUpdates ) {
2025-01-03 14:07:27 -06:00
req . libraryItem . changed ( 'updatedAt' , true )
await req . libraryItem . save ( )
2025-01-05 12:05:01 -06:00
await req . libraryItem . saveMetadataFile ( )
2022-08-19 18:41:58 -05:00
if ( isPodcastAutoDownloadUpdated ) {
2025-01-02 17:21:07 -06:00
this . cronManager . checkUpdatePodcastCron ( req . libraryItem )
2022-08-19 18:41:58 -05:00
}
2025-01-02 17:21:07 -06:00
Logger . debug ( ` [LibraryItemController] Updated library item media ${ req . libraryItem . media . title } ` )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2022-03-11 19:46:32 -06:00
}
2022-03-14 08:12:28 -05:00
res . json ( {
updated : hasUpdates ,
2025-01-02 17:21:07 -06:00
libraryItem : req . libraryItem . toOldJSON ( )
2022-03-14 08:12:28 -05:00
} )
2022-03-11 19:46:32 -06:00
}
2024-08-11 16:07:29 -05:00
/ * *
* POST : / a p i / i t e m s / : i d / c o v e r
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2025-01-07 09:05:53 -06:00
* @ param { boolean } [ updateAndReturnJson = true ] - Allows the function to be used for both direct API calls and internally
2024-08-11 16:07:29 -05:00
* /
2024-02-01 12:03:12 +02:00
async uploadCover ( req , res , updateAndReturnJson = true ) {
2024-08-11 16:07:29 -05:00
if ( ! req . user . canUpload ) {
Logger . warn ( ` User " ${ req . user . username } " attempted to upload a cover without permission ` )
2022-03-12 17:45:32 -06:00
return res . sendStatus ( 403 )
}
2023-10-13 16:33:47 -05:00
let result = null
if ( req . body ? . url ) {
2022-03-12 17:45:32 -06:00
Logger . debug ( ` [LibraryItemController] Requesting download cover from url " ${ req . body . url } " ` )
2025-01-02 15:42:52 -06:00
result = await CoverManager . downloadCoverFromUrlNew ( req . body . url , req . libraryItem . id , req . libraryItem . isFile ? null : req . libraryItem . path )
2023-10-13 16:33:47 -05:00
} else if ( req . files ? . cover ) {
2022-03-12 17:45:32 -06:00
Logger . debug ( ` [LibraryItemController] Handling uploaded cover ` )
2025-01-02 15:42:52 -06:00
result = await CoverManager . uploadCover ( req . libraryItem , req . files . cover )
2022-03-12 17:45:32 -06:00
} else {
return res . status ( 400 ) . send ( 'Invalid request no file or url' )
}
2023-10-13 16:33:47 -05:00
if ( result ? . error ) {
2022-03-12 17:45:32 -06:00
return res . status ( 400 ) . send ( result . error )
2023-10-13 16:33:47 -05:00
} else if ( ! result ? . cover ) {
2022-03-12 17:45:32 -06:00
return res . status ( 500 ) . send ( 'Unknown error occurred' )
}
2025-01-07 09:05:53 -06:00
req . libraryItem . media . coverPath = result . cover
2026-02-17 19:30:48 +02:00
req . libraryItem . media . coverWidth = result . width
req . libraryItem . media . coverHeight = result . height
2025-01-07 09:05:53 -06:00
req . libraryItem . media . changed ( 'coverPath' , true )
await req . libraryItem . media . save ( )
2025-01-02 15:42:52 -06:00
2025-01-07 09:05:53 -06:00
if ( updateAndReturnJson ) {
2025-01-02 15:42:52 -06:00
// client uses updatedAt timestamp in URL to force refresh cover
req . libraryItem . changed ( 'updatedAt' , true )
await req . libraryItem . save ( )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2024-02-01 12:03:12 +02:00
res . json ( {
success : true ,
cover : result . cover
} )
}
2022-03-12 17:45:32 -06:00
}
2024-08-11 16:07:29 -05:00
/ * *
* PATCH : / a p i / i t e m s / : i d / c o v e r
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
* /
2022-03-12 17:45:32 -06:00
async updateCover ( req , res ) {
if ( ! req . body . cover ) {
2023-04-09 15:01:14 -05:00
return res . status ( 400 ) . send ( 'Invalid request no cover path' )
2022-03-12 17:45:32 -06:00
}
2025-01-02 15:42:52 -06:00
const validationResult = await CoverManager . validateCoverPath ( req . body . cover , req . libraryItem )
2022-03-12 17:45:32 -06:00
if ( validationResult . error ) {
return res . status ( 500 ) . send ( validationResult . error )
}
if ( validationResult . updated ) {
2025-01-02 15:42:52 -06:00
req . libraryItem . media . coverPath = validationResult . cover
2026-02-17 19:30:48 +02:00
req . libraryItem . media . coverWidth = validationResult . width
req . libraryItem . media . coverHeight = validationResult . height
2025-01-02 15:42:52 -06:00
req . libraryItem . media . changed ( 'coverPath' , true )
await req . libraryItem . media . save ( )
// client uses updatedAt timestamp in URL to force refresh cover
req . libraryItem . changed ( 'updatedAt' , true )
await req . libraryItem . save ( )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2022-03-12 17:45:32 -06:00
}
res . json ( {
success : true ,
cover : validationResult . cover
} )
}
2024-08-11 16:07:29 -05:00
/ * *
* DELETE : / a p i / i t e m s / : i d / c o v e r
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
* /
2022-03-12 17:45:32 -06:00
async removeCover ( req , res ) {
2025-01-02 12:49:58 -06:00
if ( req . libraryItem . media . coverPath ) {
2025-01-02 15:42:52 -06:00
req . libraryItem . media . coverPath = null
req . libraryItem . media . changed ( 'coverPath' , true )
await req . libraryItem . media . save ( )
// client uses updatedAt timestamp in URL to force refresh cover
req . libraryItem . changed ( 'updatedAt' , true )
await req . libraryItem . save ( )
2025-01-02 12:49:58 -06:00
await CacheManager . purgeCoverCache ( req . libraryItem . id )
2025-01-02 15:42:52 -06:00
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2022-03-12 17:45:32 -06:00
}
res . sendStatus ( 200 )
}
2023-09-21 16:57:48 -05:00
/ * *
2024-08-11 16:07:29 -05:00
* GET : / a p i / i t e m s / : i d / c o v e r
2024-06-22 16:42:13 -05:00
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-09-21 16:57:48 -05:00
* /
2022-03-10 18:45:02 -06:00
async getCover ( req , res ) {
2024-06-22 16:42:13 -05:00
const {
query : { width , height , format , raw }
} = req
2023-09-21 16:57:48 -05:00
2024-11-02 09:05:30 +02:00
if ( req . query . ts ) res . set ( 'Cache-Control' , 'private, max-age=86400' )
2022-12-04 16:23:15 -06:00
2024-11-02 09:05:30 +02:00
const libraryItemId = req . params . id
if ( ! libraryItemId ) {
return res . sendStatus ( 400 )
2023-09-21 16:57:48 -05:00
}
2024-06-22 16:42:13 -05:00
if ( raw ) {
2024-11-02 12:56:40 -05:00
const coverPath = await Database . libraryItemModel . getCoverPath ( libraryItemId )
2024-11-02 09:05:30 +02:00
if ( ! coverPath || ! ( await fs . pathExists ( coverPath ) ) ) {
return res . sendStatus ( 404 )
}
2024-06-22 16:42:13 -05:00
// 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 ) {
2024-11-02 09:05:30 +02:00
const encodedURI = encodeUriPath ( global . XAccel + coverPath )
2023-09-18 13:08:19 -07:00
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
}
2024-11-02 09:05:30 +02:00
return res . sendFile ( coverPath )
2022-12-04 16:23:15 -06:00
}
2022-03-10 18:45:02 -06:00
const options = {
format : format || ( reqSupportsWebp ( req ) ? 'webp' : 'jpeg' ) ,
height : height ? parseInt ( height ) : null ,
width : width ? parseInt ( width ) : null
}
2024-11-02 09:05:30 +02:00
return CacheManager . handleCoverCache ( res , libraryItemId , options )
2022-03-10 18:45:02 -06:00
}
2024-08-11 15:15:34 -05:00
/ * *
* POST : / a p i / i t e m s / : i d / p l a y
*
* @ this { import ( '../routers/ApiRouter' ) }
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 15:15:34 -05:00
* @ param { Response } res
* /
2022-03-15 19:28:54 -05:00
startPlaybackSession ( req , res ) {
2025-01-02 12:49:58 -06:00
if ( ! req . libraryItem . hasAudioTracks ) {
2022-03-26 11:59:34 -05:00
Logger . error ( ` [LibraryItemController] startPlaybackSession cannot playback ${ req . libraryItem . id } ` )
2022-03-17 19:10:47 -05:00
return res . sendStatus ( 404 )
}
2022-05-26 19:09:46 -05:00
this . playbackSessionManager . startSessionRequest ( req , res , null )
2022-03-26 17:41:26 -05:00
}
2024-08-11 15:15:34 -05: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' ) }
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 15:15:34 -05:00
* @ param { Response } res
* /
2022-03-26 17:41:26 -05:00
startEpisodePlaybackSession ( req , res ) {
2025-01-02 12:49:58 -06:00
if ( ! req . libraryItem . isPodcast ) {
Logger . error ( ` [LibraryItemController] startEpisodePlaybackSession invalid media type ${ req . libraryItem . id } ` )
return res . sendStatus ( 400 )
2022-03-26 17:41:26 -05:00
}
2025-01-02 12:49:58 -06:00
const episodeId = req . params . episodeId
if ( ! req . libraryItem . media . podcastEpisodes . some ( ( ep ) => ep . id === episodeId ) ) {
Logger . error ( ` [LibraryItemController] startPlaybackSession episode ${ episodeId } not found for item ${ req . libraryItem . id } ` )
2022-03-26 17:41:26 -05:00
return res . sendStatus ( 404 )
}
2022-05-26 19:09:46 -05:00
this . playbackSessionManager . startSessionRequest ( req , res , episodeId )
2022-03-26 11:59:34 -05:00
}
2024-08-11 16:07:29 -05:00
/ * *
* PATCH : / a p i / i t e m s / : i d / t r a c k s
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
* /
2022-03-26 11:59:34 -05:00
async updateTracks ( req , res ) {
2025-01-02 12:49:58 -06:00
const orderedFileData = req . body ? . orderedFileData
if ( ! req . libraryItem . isBook ) {
Logger . error ( ` [LibraryItemController] updateTracks invalid media type ${ req . libraryItem . id } ` )
return res . sendStatus ( 400 )
}
if ( ! Array . isArray ( orderedFileData ) || ! orderedFileData . length ) {
Logger . error ( ` [LibraryItemController] updateTracks invalid orderedFileData ${ req . libraryItem . id } ` )
return res . sendStatus ( 400 )
2022-03-26 11:59:34 -05:00
}
2025-01-02 15:42:52 -06:00
// Ensure that each orderedFileData has a valid ino and is in the book audioFiles
if ( orderedFileData . some ( ( fileData ) => ! fileData ? . ino || ! req . libraryItem . media . audioFiles . some ( ( af ) => af . ino === fileData . ino ) ) ) {
Logger . error ( ` [LibraryItemController] updateTracks invalid orderedFileData ${ req . libraryItem . id } ` )
return res . sendStatus ( 400 )
}
2025-01-02 12:49:58 -06:00
2025-01-02 15:42:52 -06:00
let index = 1
const updatedAudioFiles = orderedFileData . map ( ( fileData ) => {
const audioFile = req . libraryItem . media . audioFiles . find ( ( af ) => af . ino === fileData . ino )
audioFile . manuallyVerified = true
audioFile . exclude = ! ! fileData . exclude
if ( audioFile . exclude ) {
audioFile . index = - 1
} else {
audioFile . index = index ++
}
return audioFile
} )
updatedAudioFiles . sort ( ( a , b ) => a . index - b . index )
req . libraryItem . media . audioFiles = updatedAudioFiles
req . libraryItem . media . changed ( 'audioFiles' , true )
await req . libraryItem . media . save ( )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2025-01-02 15:42:52 -06:00
res . json ( req . libraryItem . toOldJSON ( ) )
2022-03-12 19:59:35 -06:00
}
2024-08-11 16:07:29 -05:00
/ * *
* POST / api / items / : id / match
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
* /
2022-03-13 19:34:31 -05:00
async match ( req , res ) {
2024-12-22 10:58:22 -06:00
const reqBody = req . body || { }
2022-03-13 19:34:31 -05:00
2024-12-22 10:58:22 -06:00
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
}
2025-01-05 12:05:01 -06:00
const matchResult = await Scanner . quickMatchLibraryItem ( this , req . libraryItem , options )
2022-03-13 19:34:31 -05:00
res . json ( matchResult )
}
2026-02-14 21:57:54 +02:00
/ * *
* POST / api / items / : id / reset - metadata
*
* @ param { LibraryItemControllerRequest } req
* @ param { Response } res
* /
async resetMetadata ( req , res ) {
if ( ! req . user . canUpdate ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to reset metadata without permission ` )
return res . sendStatus ( 403 )
}
if ( global . MetadataPath ) {
const metadataPath = Path . join ( global . MetadataPath , 'items' , req . libraryItem . id , 'metadata.json' )
if ( await fs . pathExists ( metadataPath ) ) {
Logger . info ( ` [LibraryItemController] Removing metadata file at " ${ metadataPath } " ` )
await fs . remove ( metadataPath )
}
}
if ( req . libraryItem . path && ! req . libraryItem . isFile ) {
const localMetadataPath = Path . join ( req . libraryItem . path , 'metadata.json' )
if ( await fs . pathExists ( localMetadataPath ) ) {
Logger . info ( ` [LibraryItemController] Removing local metadata file at " ${ localMetadataPath } " ` )
await fs . remove ( localMetadataPath )
}
}
// Clear cover path to force re-scan of cover
if ( req . libraryItem . media . coverPath ) {
req . libraryItem . media . coverPath = null
await req . libraryItem . media . save ( )
}
// Trigger a scan ensuring we don't rely on cache/timestamps if possible
// scanLibraryItem checks mtime but since we deleted metadata.json which might have been the source,
// the "comparison" logic in BookScanner should now fallback to other sources (tags/folder).
// If those sources yield different data than DB, it updates.
const result = await LibraryItemScanner . scanLibraryItem ( req . libraryItem . id )
// Respond with updated item
await req . libraryItem . reload ( )
res . json ( req . libraryItem . toOldJSONExpanded ( ) )
}
2024-08-03 17:09:17 -05: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-12-01 09:51:26 -06:00
* @ this { import ( '../routers/ApiRouter' ) }
*
2024-08-11 16:07:29 -05:00
* @ param { RequestWithUser } req
* @ param { Response } res
2024-08-03 17:09:17 -05:00
* /
2022-03-13 17:10:48 -05:00
async batchDelete ( req , res ) {
2024-08-11 16:07:29 -05:00
if ( ! req . user . canDelete ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to delete without permission ` )
2022-03-13 17:10:48 -05:00
return res . sendStatus ( 403 )
}
2023-04-14 16:44:41 -05:00
const hardDelete = req . query . hard == 1 // Delete files from filesystem
2022-03-13 17:10:48 -05:00
2023-04-14 16:44:41 -05:00
const { libraryItemIds } = req . body
2025-01-04 15:20:41 -06:00
if ( ! libraryItemIds ? . length || ! Array . isArray ( libraryItemIds ) ) {
2023-08-12 17:29:08 -05:00
return res . status ( 400 ) . send ( 'Invalid request body' )
2022-03-13 17:10:48 -05:00
}
2025-01-04 15:20:41 -06:00
const itemsToDelete = await Database . libraryItemModel . findAllExpandedWhere ( {
2023-08-12 17:29:08 -05:00
id : libraryItemIds
} )
2022-03-13 17:10:48 -05:00
if ( ! itemsToDelete . length ) {
return res . sendStatus ( 404 )
}
2023-08-12 17:29:08 -05:00
2023-09-02 10:46:47 -05:00
const libraryId = itemsToDelete [ 0 ] . libraryId
2023-08-12 17:29:08 -05:00
for ( const libraryItem of itemsToDelete ) {
const libraryItemPath = libraryItem . path
2025-01-04 15:20:41 -06:00
Logger . info ( ` [LibraryItemController] ( ${ hardDelete ? 'Hard' : 'Soft' } ) deleting Library Item " ${ libraryItem . media . title } " with id " ${ libraryItem . id } " ` )
2024-12-01 09:51:26 -06:00
const mediaItemIds = [ ]
const seriesIds = [ ]
const authorIds = [ ]
if ( libraryItem . isPodcast ) {
2025-01-04 15:20:41 -06:00
mediaItemIds . push ( ... libraryItem . media . podcastEpisodes . map ( ( ep ) => ep . id ) )
2024-12-01 09:51:26 -06:00
} else {
mediaItemIds . push ( libraryItem . media . id )
2025-01-04 15:20:41 -06:00
if ( libraryItem . media . series ? . length ) {
seriesIds . push ( ... libraryItem . media . series . map ( ( se ) => se . id ) )
2024-12-01 09:51:26 -06:00
}
2025-01-04 15:20:41 -06:00
if ( libraryItem . media . authors ? . length ) {
authorIds . push ( ... libraryItem . media . authors . map ( ( au ) => au . id ) )
2024-12-01 09:51:26 -06:00
}
}
await this . handleDeleteLibraryItem ( libraryItem . id , mediaItemIds )
2023-04-14 16:44:41 -05: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 )
} )
}
2024-12-01 09:51:26 -06:00
if ( seriesIds . length ) {
await this . checkRemoveEmptySeries ( seriesIds )
}
if ( authorIds . length ) {
await this . checkRemoveAuthorsWithNoBooks ( authorIds )
}
2022-03-13 17:10:48 -05:00
}
2023-08-20 13:16:53 -05:00
2023-09-02 10:46:47 -05:00
await Database . resetLibraryIssuesFilterData ( libraryId )
2022-03-13 17:10:48 -05:00
res . sendStatus ( 200 )
}
2024-08-11 16:07:29 -05:00
/ * *
* POST : / a p i / i t e m s / b a t c h / u p d a t e
*
2024-12-01 09:51:26 -06:00
* @ this { import ( '../routers/ApiRouter' ) }
*
2024-08-11 16:07:29 -05:00
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2022-03-13 17:10:48 -05:00
async batchUpdate ( req , res ) {
2023-08-12 17:29:08 -05:00
const updatePayloads = req . body
2024-12-01 12:49:39 -06:00
if ( ! Array . isArray ( updatePayloads ) || ! updatePayloads . length ) {
Logger . error ( ` [LibraryItemController] Batch update failed. Invalid payload ` )
return res . sendStatus ( 400 )
2022-03-13 17:10:48 -05:00
}
2024-12-01 09:51:26 -06:00
// Ensure that each update payload has a unique library item id
2024-12-01 12:49:39 -06:00
const libraryItemIds = [ ... new Set ( updatePayloads . map ( ( up ) => up ? . id ) . filter ( ( id ) => id ) ) ]
2024-12-01 09:51:26 -06:00
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
2025-01-04 15:20:41 -06:00
const libraryItems = await Database . libraryItemModel . findAllExpandedWhere ( {
2024-12-01 09:51:26 -06:00
id : libraryItemIds
} )
if ( updatePayloads . length !== libraryItems . length ) {
Logger . error ( ` [LibraryItemController] Batch update failed. Not all library items found ` )
return res . sendStatus ( 404 )
}
2023-08-12 17:29:08 -05:00
let itemsUpdated = 0
2022-03-13 17:10:48 -05:00
2024-12-01 09:51:26 -06:00
const seriesIdsRemoved = [ ]
const authorIdsRemoved = [ ]
2023-08-12 17:29:08 -05:00
for ( const updatePayload of updatePayloads ) {
const mediaPayload = updatePayload . mediaPayload
2024-12-01 09:51:26 -06:00
const libraryItem = libraryItems . find ( ( li ) => li . id === updatePayload . id )
2022-03-13 17:10:48 -05:00
2025-01-05 12:05:01 -06:00
let hasUpdates = await libraryItem . media . updateFromRequest ( mediaPayload )
2022-03-13 17:10:48 -05:00
2025-01-05 12:05:01 -06:00
if ( libraryItem . isBook && Array . isArray ( mediaPayload . metadata ? . series ) ) {
const seriesUpdateData = await libraryItem . media . updateSeriesFromRequest ( mediaPayload . metadata . series , libraryItem . libraryId )
if ( seriesUpdateData ? . seriesRemoved . length ) {
seriesIdsRemoved . push ( ... seriesUpdateData . seriesRemoved . map ( ( se ) => se . id ) )
}
if ( seriesUpdateData ? . seriesAdded . length ) {
seriesUpdateData . seriesAdded . forEach ( ( se ) => {
Database . addSeriesToFilterData ( libraryItem . libraryId , se . name , se . id )
} )
2024-12-01 09:51:26 -06:00
}
2025-01-05 12:05:01 -06:00
if ( seriesUpdateData ? . hasUpdates ) {
hasUpdates = true
}
}
if ( libraryItem . isBook && Array . isArray ( mediaPayload . metadata ? . authors ) ) {
const authorNames = mediaPayload . metadata . authors . map ( ( au ) => ( typeof au . name === 'string' ? au . name . trim ( ) : null ) ) . filter ( ( au ) => au )
const authorUpdateData = await libraryItem . media . updateAuthorsFromRequest ( authorNames , libraryItem . libraryId )
if ( authorUpdateData ? . authorsRemoved . length ) {
authorIdsRemoved . push ( ... authorUpdateData . authorsRemoved . map ( ( au ) => au . id ) )
hasUpdates = true
}
if ( authorUpdateData ? . authorsAdded . length ) {
authorUpdateData . authorsAdded . forEach ( ( au ) => {
Database . addAuthorToFilterData ( libraryItem . libraryId , au . name , au . id )
} )
hasUpdates = true
2024-12-01 09:51:26 -06:00
}
2023-08-17 17:58:57 -05:00
}
2025-01-04 15:20:41 -06:00
if ( hasUpdates ) {
libraryItem . changed ( 'updatedAt' , true )
await libraryItem . save ( )
2023-08-17 17:58:57 -05:00
2025-01-05 12:05:01 -06:00
await libraryItem . saveMetadataFile ( )
2025-01-04 15:20:41 -06:00
Logger . debug ( ` [LibraryItemController] Updated library item media " ${ libraryItem . media . title } " ` )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , libraryItem )
2022-03-13 17:10:48 -05:00
itemsUpdated ++
}
}
2024-12-01 09:51:26 -06:00
if ( seriesIdsRemoved . length ) {
await this . checkRemoveEmptySeries ( seriesIdsRemoved )
}
if ( authorIdsRemoved . length ) {
await this . checkRemoveAuthorsWithNoBooks ( authorIdsRemoved )
}
2022-03-13 17:10:48 -05:00
res . json ( {
success : true ,
updates : itemsUpdated
} )
}
2024-08-11 16:07:29 -05: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 17:10:48 -05:00
async batchGet ( req , res ) {
2022-12-12 17:36:53 -06:00
const libraryItemIds = req . body . libraryItemIds || [ ]
2022-03-13 17:10:48 -05:00
if ( ! libraryItemIds . length ) {
return res . status ( 403 ) . send ( 'Invalid payload' )
}
2025-01-04 15:20:41 -06:00
const libraryItems = await Database . libraryItemModel . findAllExpandedWhere ( {
2023-08-12 17:29:08 -05:00
id : libraryItemIds
2022-11-19 10:20:10 -06:00
} )
2022-11-29 11:37:45 -06:00
res . json ( {
2025-01-04 15:20:41 -06:00
libraryItems : libraryItems . map ( ( li ) => li . toOldJSONExpanded ( ) )
2022-11-29 11:37:45 -06:00
} )
2022-03-13 17:10:48 -05:00
}
2024-08-11 16:07:29 -05: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 17:51:34 +01:00
async batchQuickMatch ( req , res ) {
2024-08-11 16:07:29 -05: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 22:17:36 +01:00
return res . sendStatus ( 403 )
}
2022-09-25 15:56:06 -05:00
2023-05-27 14:51:03 -05:00
let itemsUpdated = 0
let itemsUnmatched = 0
2022-09-23 17:51:34 +01:00
2023-05-27 14:51:03 -05:00
if ( ! req . body . libraryItemIds ? . length ) {
return res . sendStatus ( 400 )
2022-09-23 17:51:34 +01:00
}
2023-05-27 14:51:03 -05:00
2025-01-04 15:20:41 -06:00
const libraryItems = await Database . libraryItemModel . findAllExpandedWhere ( {
2023-09-04 16:33:55 -05:00
id : req . body . libraryItemIds
} )
2023-05-27 14:51:03 -05:00
if ( ! libraryItems ? . length ) {
return res . sendStatus ( 400 )
}
2022-09-24 23:38:18 +01:00
res . sendStatus ( 200 )
2022-09-25 15:56:06 -05:00
2024-12-22 10:58:22 -06:00
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
}
2023-05-27 14:51:03 -05:00
for ( const libraryItem of libraryItems ) {
2025-01-05 12:05:01 -06:00
const matchResult = await Scanner . quickMatchLibraryItem ( this , libraryItem , options )
2022-09-25 15:56:06 -05:00
if ( matchResult . updated ) {
itemsUpdated ++
} else if ( matchResult . warning ) {
itemsUnmatched ++
}
2022-09-23 19:37:30 +01:00
}
2022-09-25 15:56:06 -05:00
2023-05-27 14:51:03 -05:00
const result = {
2022-09-23 19:37:30 +01:00
success : itemsUpdated > 0 ,
2022-09-24 18:57:09 +01:00
updates : itemsUpdated ,
2022-09-24 22:17:36 +01:00
unmatched : itemsUnmatched
2022-09-24 23:38:44 +01:00
}
2024-08-11 16:07:29 -05:00
SocketAuthority . clientEmitter ( req . user . id , 'batch_quickmatch_complete' , result )
2022-09-23 17:51:34 +01:00
}
2024-08-11 16:07:29 -05: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 14:51:03 -05:00
async batchScan ( req , res ) {
2024-08-11 16:07:29 -05: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 14:51:03 -05:00
return res . sendStatus ( 403 )
}
if ( ! req . body . libraryItemIds ? . length ) {
return res . sendStatus ( 400 )
}
2023-09-04 11:50:55 -05:00
const libraryItems = await Database . libraryItemModel . findAll ( {
where : {
id : req . body . libraryItemIds
} ,
attributes : [ 'id' , 'libraryId' , 'isFile' ]
} )
2023-05-27 14:51:03 -05:00
if ( ! libraryItems ? . length ) {
return res . sendStatus ( 400 )
}
res . sendStatus ( 200 )
2023-09-02 10:46:47 -05:00
const libraryId = libraryItems [ 0 ] . libraryId
2023-05-27 14:51:03 -05:00
for ( const libraryItem of libraryItems ) {
if ( libraryItem . isFile ) {
Logger . warn ( ` [LibraryItemController] Re-scanning file library items not yet supported ` )
} else {
2023-09-04 11:50:55 -05:00
await LibraryItemScanner . scanLibraryItem ( libraryItem . id )
2023-05-27 14:51:03 -05:00
}
}
2023-08-20 13:16:53 -05:00
2023-09-02 10:46:47 -05:00
await Database . resetLibraryIssuesFilterData ( libraryId )
2023-05-27 14:51:03 -05:00
}
2026-02-06 14:51:54 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / m o v e
* Move multiple library items to a different library
*
* @ this { import ( '../routers/ApiRouter' ) }
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
async batchMove ( req , res ) {
if ( ! req . user . canDelete ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to batch move items without permission ` )
return res . sendStatus ( 403 )
}
const { libraryItemIds , targetLibraryId , targetFolderId } = req . body
if ( ! libraryItemIds ? . length || ! Array . isArray ( libraryItemIds ) ) {
return res . status ( 400 ) . send ( 'libraryItemIds must be an array' )
}
if ( ! targetLibraryId ) {
return res . status ( 400 ) . send ( 'targetLibraryId is required' )
}
const targetLibrary = await Database . libraryModel . findByIdWithFolders ( targetLibraryId )
if ( ! targetLibrary ) {
return res . status ( 404 ) . send ( 'Target library not found' )
}
let targetFolder = null
if ( targetFolderId ) {
targetFolder = targetLibrary . libraryFolders . find ( ( f ) => f . id === targetFolderId )
if ( ! targetFolder ) {
return res . status ( 400 ) . send ( 'Target folder not found in library' )
}
} else {
targetFolder = targetLibrary . libraryFolders [ 0 ]
}
if ( ! targetFolder ) {
return res . status ( 400 ) . send ( 'Target library has no folders' )
}
const libraryItems = await Database . libraryItemModel . findAllExpandedWhere ( {
id : libraryItemIds
} )
if ( ! libraryItems . length ) {
return res . sendStatus ( 404 )
}
let successCount = 0
let failCount = 0
const errors = [ ]
2026-02-06 22:25:44 +02:00
const sourceLibraryIds = new Set ( )
2026-02-06 14:51:54 +02:00
for ( const libraryItem of libraryItems ) {
try {
if ( libraryItem . libraryId === targetLibrary . id ) {
Logger . warn ( ` [LibraryItemController] Item " ${ libraryItem . media . title } " is already in library ${ targetLibrary . id } ` )
continue
}
const sourceLibrary = await Database . libraryModel . findByPk ( libraryItem . libraryId )
2026-02-06 22:25:44 +02:00
if ( ! sourceLibrary ) {
Logger . error ( ` [LibraryItemController] Source library not found for item ${ libraryItem . id } ` )
failCount ++
errors . push ( { id : libraryItem . id , error : 'Source library not found' } )
continue
}
sourceLibraryIds . add ( sourceLibrary . id )
2026-02-06 14:51:54 +02:00
if ( sourceLibrary . mediaType !== targetLibrary . mediaType ) {
2026-02-06 22:25:44 +02:00
Logger . warn ( ` [LibraryItemController] Cannot move ${ sourceLibrary . mediaType } to ${ targetLibrary . mediaType } library ` )
failCount ++
errors . push ( { id : libraryItem . id , error : 'Incompatible media type' } )
continue
2026-02-06 14:51:54 +02:00
}
2026-02-06 22:35:03 +02:00
await handleMoveLibraryItem ( libraryItem , targetLibrary , targetFolder )
successCount ++
2026-02-06 22:25:44 +02:00
} catch ( error ) {
2026-02-06 14:51:54 +02:00
failCount ++
2026-02-06 22:35:03 +02:00
errors . push ( { id : libraryItem . id , error : error . message || 'Failed to move item' } )
2026-02-06 14:51:54 +02:00
}
}
2026-02-06 22:25:44 +02:00
// Reset filter data and clear caches once after batch move
for ( const sourceLibraryId of sourceLibraryIds ) {
await Database . resetLibraryIssuesFilterData ( sourceLibraryId )
if ( Database . libraryFilterData [ sourceLibraryId ] ) delete Database . libraryFilterData [ sourceLibraryId ]
}
await Database . resetLibraryIssuesFilterData ( targetLibrary . id )
if ( Database . libraryFilterData [ targetLibrary . id ] ) delete Database . libraryFilterData [ targetLibrary . id ]
const firstItem = libraryItems [ 0 ]
if ( firstItem . isBook ) {
libraryItemsBookFilters . clearCountCache ( 'batch_move_items' )
} else if ( firstItem . isPodcast ) {
libraryItemsPodcastFilters . clearCountCache ( 'podcast' , 'batch_move_items' )
}
2026-02-06 14:51:54 +02:00
res . json ( {
success : true ,
successCount ,
failCount ,
errors
} )
}
2024-08-11 16:07:29 -05:00
/ * *
* POST : / a p i / i t e m s / : i d / s c a n
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
* /
2022-03-18 11:51:55 -05:00
async scan ( req , res ) {
2024-08-11 16:07:29 -05:00
if ( ! req . user . isAdminOrUp ) {
Logger . error ( ` [LibraryItemController] Non-admin user " ${ req . user . username } " attempted to scan library item ` )
2022-03-18 11:51:55 -05:00
return res . sendStatus ( 403 )
}
2022-04-27 19:42:34 -05:00
if ( req . libraryItem . isFile ) {
Logger . error ( ` [LibraryItemController] Re-scanning file library items not yet supported ` )
return res . sendStatus ( 500 )
}
2023-09-04 11:50:55 -05:00
const result = await LibraryItemScanner . scanLibraryItem ( req . libraryItem . id )
2023-08-20 13:16:53 -05:00
await Database . resetLibraryIssuesFilterData ( req . libraryItem . libraryId )
2022-03-18 11:51:55 -05:00
res . json ( {
2024-06-22 16:42:13 -05:00
result : Object . keys ( ScanResult ) . find ( ( key ) => ScanResult [ key ] == result )
2022-03-18 11:51:55 -05:00
} )
}
2024-08-11 16:07:29 -05: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
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
* /
2024-07-06 16:00:48 -05:00
getMetadataObject ( req , res ) {
2024-08-11 16:07:29 -05:00
if ( ! req . user . isAdminOrUp ) {
Logger . error ( ` [LibraryItemController] Non-admin user " ${ req . user . username } " attempted to get metadata object ` )
2022-09-25 15:56:06 -05:00
return res . sendStatus ( 403 )
}
2025-01-02 12:49:58 -06:00
if ( req . libraryItem . isMissing || ! req . libraryItem . isBook || ! req . libraryItem . media . includedAudioFiles . length ) {
2025-05-02 15:06:31 -05:00
Logger . error ( ` [LibraryItemController] getMetadataObject: Invalid library item " ${ req . libraryItem . media . title } " ` )
return res . sendStatus ( 400 )
2022-09-25 15:56:06 -05:00
}
2025-01-02 15:42:52 -06:00
res . json ( this . audioMetadataManager . getMetadataObjectForApi ( req . libraryItem ) )
2022-09-25 15:56:06 -05:00
}
2024-08-11 16:07:29 -05:00
/ * *
* POST : / a p i / i t e m s / : i d / c h a p t e r s
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
* /
2022-05-10 17:03:41 -05:00
async updateMediaChapters ( req , res ) {
2024-08-11 16:07:29 -05:00
if ( ! req . user . canUpdate ) {
Logger . error ( ` [LibraryItemController] User " ${ req . user . username } " attempted to update chapters with invalid permissions ` )
2022-05-10 17:03:41 -05:00
return res . sendStatus ( 403 )
}
2025-01-02 15:42:52 -06:00
if ( req . libraryItem . isMissing || ! req . libraryItem . isBook || ! req . libraryItem . media . hasAudioTracks ) {
2022-05-10 17:03:41 -05:00
Logger . error ( ` [LibraryItemController] Invalid library item ` )
return res . sendStatus ( 500 )
}
2025-01-02 15:42:52 -06:00
if ( ! Array . isArray ( req . body . chapters ) || req . body . chapters . some ( ( c ) => ! c . title || typeof c . title !== 'string' || c . start === undefined || typeof c . start !== 'number' || c . end === undefined || typeof c . end !== 'number' ) ) {
2022-05-10 17:03:41 -05:00
Logger . error ( ` [LibraryItemController] Invalid payload ` )
return res . sendStatus ( 400 )
}
2023-04-09 12:47:36 -05:00
const chapters = req . body . chapters || [ ]
2025-01-02 15:42:52 -06:00
let hasUpdates = false
if ( chapters . length !== req . libraryItem . media . chapters . length ) {
req . libraryItem . media . chapters = chapters . map ( ( c , index ) => {
return {
id : index ,
title : c . title ,
start : c . start ,
end : c . end
}
} )
hasUpdates = true
} else {
for ( const [ index , chapter ] of chapters . entries ( ) ) {
const currentChapter = req . libraryItem . media . chapters [ index ]
if ( currentChapter . title !== chapter . title || currentChapter . start !== chapter . start || currentChapter . end !== chapter . end ) {
currentChapter . title = chapter . title
currentChapter . start = chapter . start
currentChapter . end = chapter . end
hasUpdates = true
}
}
}
if ( hasUpdates ) {
req . libraryItem . media . changed ( 'chapters' , true )
await req . libraryItem . media . save ( )
2025-01-05 12:05:01 -06:00
await req . libraryItem . saveMetadataFile ( )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2022-05-10 17:03:41 -05:00
}
res . json ( {
success : true ,
2025-01-02 15:42:52 -06:00
updated : hasUpdates
2022-05-10 17:03:41 -05:00
} )
}
2023-06-25 16:16:11 -05:00
/ * *
2024-08-11 16:07:29 -05: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 16:16:11 -05:00
* FFProbe JSON result from audio file
2024-06-22 16:42:13 -05:00
*
2025-01-02 15:42:52 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-06-25 16:16:11 -05:00
* /
async getFFprobeData ( req , res ) {
2024-08-11 16:07:29 -05:00
if ( ! req . user . isAdminOrUp ) {
Logger . error ( ` [LibraryItemController] Non-admin user " ${ req . user . username } " attempted to get ffprobe data ` )
2023-06-25 16:16:11 -05:00
return res . sendStatus ( 403 )
}
2022-10-02 15:24:32 -05:00
2025-01-02 15:42:52 -06:00
const audioFile = req . libraryItem . getAudioFileWithIno ( req . params . fileid )
2022-10-02 15:24:32 -05:00
if ( ! audioFile ) {
2023-06-25 16:16:11 -05:00
Logger . error ( ` [LibraryItemController] Audio file not found with inode value ${ req . params . fileid } ` )
2022-10-02 15:24:32 -05:00
return res . sendStatus ( 404 )
}
2025-01-02 15:42:52 -06:00
const ffprobeData = await AudioFileScanner . probeAudioFile ( audioFile . metadata . path )
2023-06-25 16:16:11 -05:00
res . json ( ffprobeData )
2022-10-02 15:24:32 -05:00
}
2023-05-28 12:34:22 -05:00
/ * *
* GET api / items / : id / file / : fileid
2024-06-22 16:42:13 -05:00
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequestWithFile } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-05-28 12:34:22 -05:00
* /
async getLibraryFile ( req , res ) {
const libraryFile = req . libraryFile
if ( global . XAccel ) {
2023-09-18 13:08:19 -07: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-13 18:03:39 -05:00
}
2023-05-28 12:34:22 -05: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 16:42:13 -05:00
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequestWithFile } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-05-28 12:34:22 -05:00
* /
async deleteLibraryFile ( req , res ) {
const libraryFile = req . libraryFile
2024-08-11 16:07:29 -05:00
Logger . info ( ` [LibraryItemController] User " ${ req . user . username } " requested file delete at " ${ libraryFile . metadata . path } " ` )
2023-05-28 12:34:22 -05:00
2023-04-14 16:44:41 -05:00
await fs . remove ( libraryFile . metadata . path ) . catch ( ( error ) => {
Logger . error ( ` [LibraryItemController] Failed to delete library file at " ${ libraryFile . metadata . path } " ` , error )
} )
2023-04-13 18:03:39 -05:00
2025-01-02 15:42:52 -06:00
req . libraryItem . libraryFiles = req . libraryItem . libraryFiles . filter ( ( lf ) => lf . ino !== req . params . fileid )
req . libraryItem . changed ( 'libraryFiles' , true )
if ( req . libraryItem . isBook ) {
if ( req . libraryItem . media . audioFiles . some ( ( af ) => af . ino === req . params . fileid ) ) {
req . libraryItem . media . audioFiles = req . libraryItem . media . audioFiles . filter ( ( af ) => af . ino !== req . params . fileid )
req . libraryItem . media . changed ( 'audioFiles' , true )
} else if ( req . libraryItem . media . ebookFile ? . ino === req . params . fileid ) {
req . libraryItem . media . ebookFile = null
req . libraryItem . media . changed ( 'ebookFile' , true )
}
if ( ! req . libraryItem . media . hasMediaFiles ) {
req . libraryItem . isMissing = true
2023-04-13 18:03:39 -05:00
}
2025-01-02 15:42:52 -06:00
} else if ( req . libraryItem . media . podcastEpisodes . some ( ( ep ) => ep . audioFile . ino === req . params . fileid ) ) {
const episodeToRemove = req . libraryItem . media . podcastEpisodes . find ( ( ep ) => ep . audioFile . ino === req . params . fileid )
2025-01-03 12:12:56 -06:00
// Remove episode from all playlists
2025-01-03 12:06:20 -06:00
await Database . playlistModel . removeMediaItemsFromPlaylists ( [ episodeToRemove . id ] )
2025-01-03 12:12:56 -06:00
// Remove episode media progress
const numProgressRemoved = await Database . mediaProgressModel . destroy ( {
where : {
mediaItemId : episodeToRemove . id
}
} )
if ( numProgressRemoved > 0 ) {
Logger . info ( ` [LibraryItemController] Removed media progress for episode ${ episodeToRemove . id } ` )
}
// Remove episode
2025-01-02 15:42:52 -06:00
await episodeToRemove . destroy ( )
req . libraryItem . media . podcastEpisodes = req . libraryItem . media . podcastEpisodes . filter ( ( ep ) => ep . audioFile . ino !== req . params . fileid )
}
if ( req . libraryItem . media . changed ( ) ) {
await req . libraryItem . media . save ( )
2023-04-13 18:03:39 -05:00
}
2025-01-02 15:42:52 -06:00
await req . libraryItem . save ( )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2023-04-13 18:03:39 -05:00
res . sendStatus ( 200 )
}
2023-05-28 12:34:22 -05: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 16:07:29 -05:00
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequestWithFile } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-05-28 12:34:22 -05:00
* /
async downloadLibraryFile ( req , res ) {
const libraryFile = req . libraryFile
2024-08-20 19:00:29 -07:00
const ua = uaParserJs ( req . headers [ 'user-agent' ] )
2023-05-28 12:34:22 -05:00
2024-08-11 16:07:29 -05: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 12:34:22 -05:00
return res . sendStatus ( 403 )
}
2025-01-02 12:49:58 -06:00
Logger . info ( ` [LibraryItemController] User " ${ req . user . username } " requested download for item " ${ req . libraryItem . media . title } " file at " ${ libraryFile . metadata . path } " ` )
2023-05-28 12:34:22 -05:00
if ( global . XAccel ) {
2023-09-18 13:08:19 -07: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 12:34:22 -05:00
}
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
2024-08-20 19:00:29 -07:00
let audioMimeType = getAudioMimeTypeFromExtname ( Path . extname ( libraryFile . metadata . path ) )
2023-05-28 12:34:22 -05:00
if ( audioMimeType ) {
2024-08-20 19:00:29 -07: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 13:27:48 -05:00
audioMimeType = 'audio/m4b'
2024-08-20 19:00:29 -07:00
}
2023-05-28 12:34:22 -05:00
res . setHeader ( 'Content-Type' , audioMimeType )
}
2024-10-28 08:03:31 +02: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 )
2024-10-29 21:42:44 +02:00
LibraryItemController . handleDownloadError ( error , res )
2024-10-28 08:03:31 +02:00
}
2023-05-28 12:34:22 -05:00
}
/ * *
2023-06-10 12:46:57 -05: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 16:42:13 -05:00
*
2025-01-02 12:49:58 -06:00
* @ param { LibraryItemControllerRequest } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-05-28 12:34:22 -05:00
* /
2023-05-28 10:47:28 -05:00
async getEBookFile ( req , res ) {
2023-06-10 12:46:57 -05:00
let ebookFile = null
if ( req . params . fileid ) {
2025-01-02 15:42:52 -06:00
ebookFile = req . libraryItem . getLibraryFileWithIno ( req . params . fileid )
2023-06-10 12:46:57 -05:00
if ( ! ebookFile ? . isEBookFile ) {
Logger . error ( ` [LibraryItemController] Invalid ebook file id " ${ req . params . fileid } " ` )
return res . status ( 400 ) . send ( 'Invalid ebook file id' )
}
} else {
2025-01-02 15:42:52 -06:00
ebookFile = req . libraryItem . media . ebookFile
2023-06-10 12:46:57 -05:00
}
2023-05-28 10:47:28 -05:00
if ( ! ebookFile ) {
2025-01-02 12:49:58 -06:00
Logger . error ( ` [LibraryItemController] No ebookFile for library item " ${ req . libraryItem . media . title } " ` )
2023-05-28 10:47:28 -05:00
return res . sendStatus ( 404 )
}
const ebookFilePath = ebookFile . metadata . path
2024-08-03 17:09:17 -05:00
2025-01-02 12:49:58 -06:00
Logger . info ( ` [LibraryItemController] User " ${ req . user . username } " requested download for item " ${ req . libraryItem . media . title } " ebook at " ${ ebookFilePath } " ` )
2023-05-28 12:34:22 -05:00
if ( global . XAccel ) {
2023-09-18 13:08:19 -07: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 12:34:22 -05:00
}
2024-10-28 08:03:31 +02: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 )
2024-10-29 21:42:44 +02:00
LibraryItemController . handleDownloadError ( error , res )
2024-10-28 08:03:31 +02:00
}
2023-05-28 10:47:28 -05:00
}
2023-06-10 12:46:57 -05: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 16:42:13 -05:00
*
2025-01-02 15:42:52 -06:00
* @ param { LibraryItemControllerRequestWithFile } req
2024-08-11 16:07:29 -05:00
* @ param { Response } res
2023-06-10 12:46:57 -05:00
* /
async updateEbookFileStatus ( req , res ) {
2025-01-02 15:42:52 -06:00
if ( ! req . libraryItem . isBook ) {
Logger . error ( ` [LibraryItemController] Invalid media type for ebook file status update ` )
return res . sendStatus ( 400 )
}
if ( ! req . libraryFile ? . isEBookFile ) {
2023-06-10 12:46:57 -05:00
Logger . error ( ` [LibraryItemController] Invalid ebook file id " ${ req . params . fileid } " ` )
return res . status ( 400 ) . send ( 'Invalid ebook file id' )
}
2025-01-02 15:42:52 -06:00
const ebookLibraryFile = req . libraryFile
let primaryEbookFile = null
const ebookLibraryFileInos = req . libraryItem
. getLibraryFiles ( )
. filter ( ( lf ) => lf . isEBookFile )
. map ( ( lf ) => lf . ino )
2023-06-10 12:46:57 -05:00
if ( ebookLibraryFile . isSupplementary ) {
Logger . info ( ` [LibraryItemController] Updating ebook file " ${ ebookLibraryFile . metadata . filename } " to primary ` )
2025-01-02 15:42:52 -06:00
primaryEbookFile = ebookLibraryFile . toJSON ( )
delete primaryEbookFile . isSupplementary
delete primaryEbookFile . fileType
primaryEbookFile . ebookFormat = ebookLibraryFile . metadata . format
2023-06-10 12:46:57 -05:00
} else {
Logger . info ( ` [LibraryItemController] Updating ebook file " ${ ebookLibraryFile . metadata . filename } " to supplementary ` )
}
2025-01-02 15:42:52 -06:00
req . libraryItem . media . ebookFile = primaryEbookFile
req . libraryItem . media . changed ( 'ebookFile' , true )
await req . libraryItem . media . save ( )
req . libraryItem . libraryFiles = req . libraryItem . libraryFiles . map ( ( lf ) => {
if ( ebookLibraryFileInos . includes ( lf . ino ) ) {
lf . isSupplementary = lf . ino !== primaryEbookFile ? . ino
}
return lf
} )
req . libraryItem . changed ( 'libraryFiles' , true )
req . libraryItem . isMissing = ! req . libraryItem . media . hasMediaFiles
await req . libraryItem . save ( )
2025-04-12 17:39:51 -05:00
SocketAuthority . libraryItemEmitter ( 'item_updated' , req . libraryItem )
2023-06-10 12:46:57 -05:00
res . sendStatus ( 200 )
}
2026-02-06 14:51:54 +02:00
/ * *
* POST : / a p i / i t e m s / : i d / m o v e
* Move a library item to a different library
*
* @ param { LibraryItemControllerRequest } req
* @ param { Response } res
* /
async move ( req , res ) {
// Permission check - require delete permission (implies write access)
if ( ! req . user . canDelete ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to move item without permission ` )
return res . sendStatus ( 403 )
}
const { targetLibraryId , targetFolderId } = req . body
if ( ! targetLibraryId ) {
return res . status ( 400 ) . send ( 'Target library ID is required' )
}
// Get target library with folders
const targetLibrary = await Database . libraryModel . findByIdWithFolders ( targetLibraryId )
if ( ! targetLibrary ) {
return res . status ( 404 ) . send ( 'Target library not found' )
}
// Validate media type compatibility
const sourceLibrary = await Database . libraryModel . findByPk ( req . libraryItem . libraryId )
if ( ! sourceLibrary ) {
Logger . error ( ` [LibraryItemController] Source library not found for item ${ req . libraryItem . id } ` )
return res . status ( 500 ) . send ( 'Source library not found' )
}
if ( sourceLibrary . mediaType !== targetLibrary . mediaType ) {
return res . status ( 400 ) . send ( ` Cannot move ${ sourceLibrary . mediaType } to ${ targetLibrary . mediaType } library ` )
}
// Don't allow moving to same library
if ( sourceLibrary . id === targetLibrary . id ) {
return res . status ( 400 ) . send ( 'Item is already in this library' )
}
// Determine target folder
let targetFolder = null
if ( targetFolderId ) {
targetFolder = targetLibrary . libraryFolders . find ( ( f ) => f . id === targetFolderId )
if ( ! targetFolder ) {
return res . status ( 400 ) . send ( 'Target folder not found in library' )
}
} else {
// Use first folder if not specified
targetFolder = targetLibrary . libraryFolders [ 0 ]
}
2026-02-06 14:14:25 +02:00
2026-02-06 14:51:54 +02:00
if ( ! targetFolder ) {
return res . status ( 400 ) . send ( 'Target library has no folders' )
}
try {
2026-02-06 22:35:03 +02:00
await handleMoveLibraryItem ( req . libraryItem , targetLibrary , targetFolder )
2026-02-06 22:25:44 +02:00
await Database . resetLibraryIssuesFilterData ( sourceLibrary . id )
await Database . resetLibraryIssuesFilterData ( targetLibrary . id )
if ( Database . libraryFilterData [ sourceLibrary . id ] ) delete Database . libraryFilterData [ sourceLibrary . id ]
if ( Database . libraryFilterData [ targetLibrary . id ] ) delete Database . libraryFilterData [ targetLibrary . id ]
if ( req . libraryItem . isBook ) {
libraryItemsBookFilters . clearCountCache ( 'move_item' )
} else if ( req . libraryItem . isPodcast ) {
libraryItemsPodcastFilters . clearCountCache ( 'podcast' , 'move_item' )
}
2026-02-06 14:51:54 +02:00
res . json ( {
success : true ,
libraryItem : req . libraryItem . toOldJSONExpanded ( )
} )
} catch ( error ) {
return res . status ( 500 ) . send ( error . message || 'Failed to move item' )
2026-02-06 14:14:25 +02:00
}
}
2026-02-13 14:15:18 +02:00
/ * *
* POST : / a p i / i t e m s / : i d / c o n s o l i d a t e
* Rename book folder to Author - Title and move to library root
*
* @ param { LibraryItemControllerRequest } req
* @ param { Response } res
* /
async consolidate ( req , res ) {
if ( ! req . libraryItem . isBook ) {
return res . status ( 400 ) . send ( 'Consolidate only available for books' )
}
2026-02-17 15:48:28 +02:00
const { merge , newName } = req . body
2026-02-13 14:15:18 +02:00
const author = req . libraryItem . media . authors ? . [ 0 ] ? . name || 'Unknown Author'
const title = req . libraryItem . media . title || 'Unknown Title'
2026-02-15 20:51:39 +02:00
const sanitizedFolderName = Database . libraryItemModel . getConsolidatedFolderName ( author , title )
2026-02-17 15:48:28 +02:00
const targetFolderName = newName || sanitizedFolderName
2026-02-13 14:15:18 +02:00
const library = await Database . libraryModel . findByIdWithFolders ( req . libraryItem . libraryId )
// Find the library folder that currently contains this item
const targetFolder = library . libraryFolders . find ( ( f ) => req . libraryItem . path . startsWith ( f . path ) ) || library . libraryFolders [ 0 ]
2026-02-17 15:48:28 +02:00
const expectedPath = Path . join ( targetFolder . path , targetFolderName )
const isSamePath = req . libraryItem . path === expectedPath
2026-02-17 15:55:38 +02:00
let existingItem = null
if ( ! isSamePath && ( await fs . pathExists ( expectedPath ) ) ) {
2026-02-17 15:48:28 +02:00
// Find existing library item at this path if any
2026-02-17 15:55:38 +02:00
existingItem = await Database . libraryItemModel . findOne ( {
2026-02-17 15:48:28 +02:00
where : {
path : expectedPath
}
} )
2026-02-17 15:55:38 +02:00
if ( ! merge ) {
return res . status ( 409 ) . json ( {
error : 'Destination already exists' ,
path : expectedPath ,
existingLibraryItemId : existingItem ? . id || null
} )
}
2026-02-17 15:48:28 +02:00
}
2026-02-13 14:15:18 +02:00
try {
2026-02-17 15:55:38 +02:00
const oldPath = req . libraryItem . path
2026-02-17 15:48:28 +02:00
await handleMoveLibraryItem ( req . libraryItem , library , targetFolder , targetFolderName , ! ! merge )
2026-02-13 14:15:18 +02:00
2026-02-17 15:55:38 +02:00
if ( merge && existingItem ) {
Logger . info ( ` [LibraryItemController] Consolidated item " ${ req . libraryItem . id } " was merged into existing item " ${ existingItem . id } " ` )
const authorIds = req . libraryItem . media . authors ? . map ( ( au ) => au . id ) || [ ]
const seriesIds = req . libraryItem . media . series ? . map ( ( se ) => se . id ) || [ ]
// Cleanup associations for the item being absorbed
await this . handleDeleteLibraryItem ( req . libraryItem . id , [ req . libraryItem . media . id ] )
// Delete the redundant database record
await req . libraryItem . destroy ( )
if ( authorIds . length ) {
await this . checkRemoveAuthorsWithNoBooks ( authorIds )
}
if ( seriesIds . length ) {
await this . checkRemoveEmptySeries ( seriesIds )
}
// Rescan target item to pick up merged files
await LibraryItemScanner . scanLibraryItem ( existingItem . id )
2026-02-17 16:19:29 +02:00
const updatedExistingItem = await Database . libraryItemModel . getExpandedById ( existingItem . id )
2026-02-17 15:55:38 +02:00
return res . json ( {
success : true ,
mergedInto : existingItem . id ,
libraryItem : updatedExistingItem . toOldJSONExpanded ( )
} )
}
// Recursively remove empty parent directories from original location
let parentDir = Path . dirname ( oldPath )
2026-02-13 14:15:18 +02:00
while ( parentDir && parentDir !== targetFolder . path && parentDir !== Path . dirname ( parentDir ) ) {
try {
const files = await fs . readdir ( parentDir )
if ( files . length === 0 ) {
await fs . remove ( parentDir )
parentDir = Path . dirname ( parentDir )
} else {
break
}
} catch ( err ) {
Logger . error ( ` [LibraryItemController] Failed to cleanup parent directory " ${ parentDir } " ` , err )
break
}
}
res . json ( {
success : true ,
libraryItem : req . libraryItem . toOldJSONExpanded ( )
} )
} catch ( error ) {
Logger . error ( ` [LibraryItemController] Failed to consolidate item " ${ req . libraryItem . media . title } " ` , error )
return res . status ( 500 ) . send ( error . message || 'Failed to consolidate item' )
}
}
/ * *
* POST : / a p i / i t e m s / b a t c h / c o n s o l i d a t e
* Consolidate multiple library items
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
async batchConsolidate ( req , res ) {
2026-02-17 15:48:28 +02:00
const { libraryItemIds , merge } = req . body
2026-02-13 14:15:18 +02:00
if ( ! Array . isArray ( libraryItemIds ) || ! libraryItemIds . length ) {
return res . status ( 400 ) . send ( 'Invalid request' )
}
const libraryItems = await Database . libraryItemModel . findAllExpandedWhere ( {
id : libraryItemIds
} )
const results = [ ]
2026-02-17 15:48:28 +02:00
let numSuccess = 0
2026-02-13 14:15:18 +02:00
for ( const libraryItem of libraryItems ) {
2026-02-17 10:56:29 +02:00
if ( libraryItem . mediaType !== 'book' ) {
results . push ( { id : libraryItem . id , success : false , error : 'Not a book' } )
2026-02-13 14:15:18 +02:00
continue
}
try {
const author = libraryItem . media . authors ? . [ 0 ] ? . name || 'Unknown Author'
const title = libraryItem . media . title || 'Unknown Title'
2026-02-15 20:51:39 +02:00
const sanitizedFolderName = Database . libraryItemModel . getConsolidatedFolderName ( author , title )
2026-02-13 14:15:18 +02:00
const library = await Database . libraryModel . findByIdWithFolders ( libraryItem . libraryId )
const currentLibraryFolder = library . libraryFolders . find ( ( lf ) => libraryItem . path . startsWith ( lf . path ) ) || library . libraryFolders [ 0 ]
2026-02-17 15:55:38 +02:00
const targetPath = Path . join ( currentLibraryFolder . path , sanitizedFolderName )
const isSamePath = libraryItem . path === targetPath
let existingItem = null
if ( ! isSamePath && ( await fs . pathExists ( targetPath ) ) ) {
existingItem = await Database . libraryItemModel . findOne ( {
where : { path : targetPath }
} )
if ( ! merge ) {
results . push ( { id : libraryItem . id , success : false , error : 'Destination already exists' } )
continue
}
}
2026-02-13 14:15:18 +02:00
const oldPath = libraryItem . path
2026-02-17 15:48:28 +02:00
await handleMoveLibraryItem ( libraryItem , library , currentLibraryFolder , sanitizedFolderName , ! ! merge )
2026-02-13 14:15:18 +02:00
2026-02-17 15:55:38 +02:00
if ( merge && existingItem ) {
Logger . info ( ` [LibraryItemController] Batch Consolidate: Merging item " ${ libraryItem . id } " into existing item " ${ existingItem . id } " ` )
const authorIds = libraryItem . media . authors ? . map ( ( au ) => au . id ) || [ ]
const seriesIds = libraryItem . media . series ? . map ( ( se ) => se . id ) || [ ]
await this . handleDeleteLibraryItem ( libraryItem . id , [ libraryItem . media . id ] )
await libraryItem . destroy ( )
if ( authorIds . length ) {
await this . checkRemoveAuthorsWithNoBooks ( authorIds )
}
if ( seriesIds . length ) {
await this . checkRemoveEmptySeries ( seriesIds )
}
await LibraryItemScanner . scanLibraryItem ( existingItem . id )
results . push ( { id : libraryItem . id , success : true , mergedInto : existingItem . id } )
numSuccess ++
continue
}
// Recursively remove empty parent directories from original location
2026-02-13 14:15:18 +02:00
let parentDir = Path . dirname ( oldPath )
while ( parentDir && parentDir !== currentLibraryFolder . path && parentDir !== Path . dirname ( parentDir ) ) {
try {
const files = await fs . readdir ( parentDir )
if ( files . length === 0 ) {
await fs . remove ( parentDir )
parentDir = Path . dirname ( parentDir )
} else {
break
}
} catch ( err ) {
Logger . error ( ` [LibraryItemController] Failed to cleanup parent directory " ${ parentDir } " ` , err )
break
}
}
results . push ( { id : libraryItem . id , success : true } )
2026-02-17 15:48:28 +02:00
numSuccess ++
2026-02-13 14:15:18 +02:00
} catch ( error ) {
Logger . error ( ` [LibraryItemController] Batch Consolidate: Failed to consolidate " ${ libraryItem . media ? . title } " ` , error )
results . push ( { id : libraryItem . id , success : false , error : error . message } )
}
}
2026-02-17 15:48:28 +02:00
res . json ( {
success : numSuccess === libraryItems . length ,
results
} )
2026-02-13 14:15:18 +02:00
}
2026-02-12 19:57:04 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / m e r g e
* Merge multiple library items into one
*
* @ this { import ( '../routers/ApiRouter' ) }
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
async batchMerge ( req , res ) {
if ( ! req . user . canDelete ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to batch merge items without permission ` )
return res . sendStatus ( 403 )
}
const { libraryItemIds } = req . body
if ( ! libraryItemIds ? . length || ! Array . isArray ( libraryItemIds ) || libraryItemIds . length < 2 ) {
return res . status ( 400 ) . send ( 'Invalid request body. Must select at least 2 items.' )
}
const libraryItems = await Database . libraryItemModel . findAllExpandedWhere ( {
id : libraryItemIds
} )
if ( libraryItems . length !== libraryItemIds . length ) {
return res . status ( 404 ) . send ( 'Some library items not found' )
}
const libraryId = libraryItems [ 0 ] . libraryId
// Validate all items are in the same library and are books
const invalidItem = libraryItems . find ( ( li ) => li . libraryId !== libraryId || li . mediaType !== 'book' )
if ( invalidItem ) {
return res . status ( 400 ) . send ( 'All items must be books in the same library' )
}
// Sort items by ID to be deterministic, user selection order is lost in findAllExpandedWhere
// To preserve user selection order, we map libraryItemIds to objects
const orderedLibraryItems = libraryItemIds . map ( ( id ) => libraryItems . find ( ( li ) => li . id === id ) ) . filter ( ( li ) => li )
const primaryItem = orderedLibraryItems [ 0 ]
const otherItems = orderedLibraryItems . slice ( 1 )
const primaryItemPath = primaryItem . path
// If primary item is file, its dir is dirname. If folder, its dir is path.
const primaryItemDir = primaryItem . isFile ? Path . dirname ( primaryItemPath ) : primaryItemPath
const library = await Database . libraryModel . findByIdWithFolders ( libraryId )
const libraryFolder = library . libraryFolders . find ( ( lf ) => primaryItemPath . startsWith ( lf . path ) )
if ( ! libraryFolder ) {
Logger . error ( ` [LibraryItemController] Library folder not found for primary item " ${ primaryItem . media . title } " path " ${ primaryItemPath } " ` )
return res . status ( 500 ) . send ( 'Library folder not found for primary item' )
}
let targetDirPath = primaryItemDir
// If primary item is a single file in the root of the library folder,
// create a new folder for the merged book.
// primaryItemDir check:
// If primaryItem.isFile is true, primaryItemDir is parent dir.
// If primaryItemDir == libraryFolder.path, it means it's in the root of library folder.
const isPrimaryInRoot = primaryItemDir === libraryFolder . path
if ( isPrimaryInRoot ) {
// Create a new folder for the merged book
const author = primaryItem . media . authors ? . [ 0 ] ? . name || 'Unknown Author'
const title = primaryItem . media . title || 'Unknown Title'
// Simple sanitization
const folderName = ` ${ author } - ${ title } ` . replace ( /[/\\?%*:|"<>]/g , '' ) . trim ( )
targetDirPath = Path . join ( libraryFolder . path , folderName )
if ( await fs . pathExists ( targetDirPath ) ) {
// Directory already exists, append timestamp to avoid conflict
targetDirPath += ` ( ${ Date . now ( ) } ) `
}
await fs . ensureDir ( targetDirPath )
// Move primary item file to new folder
const newPrimaryPath = Path . join ( targetDirPath , Path . basename ( primaryItemPath ) )
await fs . move ( primaryItemPath , newPrimaryPath )
// Update primary item path in memory (DB update will happen on scan)
primaryItem . path = newPrimaryPath
primaryItem . relPath = Path . relative ( libraryFolder . path , newPrimaryPath )
}
Logger . info ( ` [LibraryItemController] Merging ${ otherItems . length } items into " ${ primaryItem . media . title } " at " ${ targetDirPath } " ` )
const successIds = [ ]
const failIds = [ ]
const failedItems = [ ]
for ( const item of otherItems ) {
try {
const itemPath = item . path
if ( item . isFile ) {
const filename = Path . basename ( itemPath )
let destPath = Path . join ( targetDirPath , filename )
// Handle collision
if ( await fs . pathExists ( destPath ) ) {
const name = Path . parse ( filename ) . name
const ext = Path . parse ( filename ) . ext
destPath = Path . join ( targetDirPath , ` ${ name } _ ${ Date . now ( ) } ${ ext } ` )
}
await fs . move ( itemPath , destPath )
} else {
// It's a directory
// Move all files from this directory to target directory
const files = await fs . readdir ( itemPath )
for ( const file of files ) {
const srcFile = Path . join ( itemPath , file )
let destFile = Path . join ( targetDirPath , file )
if ( await fs . pathExists ( destFile ) ) {
const name = Path . parse ( file ) . name
const ext = Path . parse ( file ) . ext
destFile = Path . join ( targetDirPath , ` ${ name } _ ${ Date . now ( ) } ${ ext } ` )
}
// If it's a directory inside, move recursively?
// Users shouldn't have nested books usually. fs.move works for dirs too.
await fs . move ( srcFile , destFile )
}
// Remove the now empty directory
await fs . remove ( itemPath )
}
// Delete the library item from DB
// We pass empty array for mediaItemIds because we moved the files, so we don't want to delete them if they were linked.
// Actually handleDeleteLibraryItem deletes from DB handling relationships.
// But we already moved the files.
// If hard delete was called, it would try to delete files. But we didn't call delete with hard=1 logic here.
// We manually moved files.
// Now we just need to remove the DB entry.
// However, handleDeleteLibraryItem removes media progress, playlists, etc.
await this . handleDeleteLibraryItem ( item . id , [ item . media . id ] )
successIds . push ( item . id )
} catch ( error ) {
Logger . error ( ` [LibraryItemController] Failed to merge item ${ item . id } ` , error )
failIds . push ( item . id )
failedItems . push ( { id : item . id , error : error . message } )
}
}
// Rescan the target folder
2026-02-13 14:15:18 +02:00
// If moved to folder, tell scanner
2026-02-12 19:57:04 +02:00
if ( isPrimaryInRoot ) {
// We changed the structure of primary item
await LibraryItemScanner . scanLibraryItem ( primaryItem . id , {
path : targetDirPath ,
relPath : Path . relative ( libraryFolder . path , targetDirPath ) ,
isFile : false
} )
} else {
// Just rescan content
await LibraryItemScanner . scanLibraryItem ( primaryItem . id )
}
// Check remove empty authors/series for deleted items
// We can collect all author/series IDs from deleted items
const authorIdsToCheck = [ ]
const seriesIdsToCheck = [ ]
otherItems . forEach ( ( item ) => {
if ( successIds . includes ( item . id ) ) {
if ( item . media . authors ) authorIdsToCheck . push ( ... item . media . authors . map ( ( a ) => a . id ) )
if ( item . media . series ) seriesIdsToCheck . push ( ... item . media . series . map ( ( s ) => s . id ) )
}
} )
if ( authorIdsToCheck . length ) await this . checkRemoveAuthorsWithNoBooks ( [ ... new Set ( authorIdsToCheck ) ] )
if ( seriesIdsToCheck . length ) await this . checkRemoveEmptySeries ( [ ... new Set ( seriesIdsToCheck ) ] )
res . json ( {
success : failIds . length === 0 ,
2026-02-13 14:15:18 +02:00
mergedItemId : primaryItem . id ,
2026-02-12 19:57:04 +02:00
successIds ,
failIds ,
errors : failedItems
} )
}
2026-02-15 16:33:16 +02:00
/ * *
* POST : / a p i / i t e m s / b a t c h / r e s e t - m e t a d a t a
* Reset metadata for multiple library items
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
async batchResetMetadata ( req , res ) {
if ( ! req . user . canUpdate ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to batch reset metadata without permission ` )
return res . sendStatus ( 403 )
}
const { libraryItemIds } = req . body
if ( ! Array . isArray ( libraryItemIds ) || ! libraryItemIds . length ) {
return res . status ( 400 ) . send ( 'Invalid request' )
}
const libraryItems = await Database . libraryItemModel . findAllExpandedWhere ( {
id : libraryItemIds
} )
const results = [ ]
for ( const libraryItem of libraryItems ) {
try {
if ( global . MetadataPath ) {
const metadataPath = Path . join ( global . MetadataPath , 'items' , libraryItem . id , 'metadata.json' )
if ( await fs . pathExists ( metadataPath ) ) {
Logger . info ( ` [LibraryItemController] Removing metadata file at " ${ metadataPath } " ` )
await fs . remove ( metadataPath )
}
}
if ( libraryItem . path && ! libraryItem . isFile ) {
const localMetadataPath = Path . join ( libraryItem . path , 'metadata.json' )
if ( await fs . pathExists ( localMetadataPath ) ) {
Logger . info ( ` [LibraryItemController] Removing local metadata file at " ${ localMetadataPath } " ` )
await fs . remove ( localMetadataPath )
}
}
// Clear cover path to force re-scan of cover
if ( libraryItem . media . coverPath ) {
libraryItem . media . coverPath = null
await libraryItem . media . save ( )
}
// Trigger a scan ensuring we don't rely on cache/timestamps if possible
await LibraryItemScanner . scanLibraryItem ( libraryItem . id )
results . push ( { id : libraryItem . id , success : true } )
} catch ( error ) {
Logger . error ( ` [LibraryItemController] Batch Reset Metadata: Failed to reset " ${ libraryItem . media ? . title } " ` , error )
results . push ( { id : libraryItem . id , success : false , error : error . message } )
}
}
res . json ( { results } )
}
2024-08-03 17:09:17 -05:00
/ * *
*
2024-08-11 16:07:29 -05:00
* @ param { RequestWithUser } req
* @ param { Response } res
* @ param { NextFunction } next
2024-08-03 17:09:17 -05:00
* /
2023-08-06 15:06:45 -05:00
async middleware ( req , res , next ) {
2025-01-02 12:49:58 -06:00
req . libraryItem = await Database . libraryItemModel . getExpandedById ( req . params . id )
2023-05-28 12:34:22 -05:00
if ( ! req . libraryItem ? . media ) return res . sendStatus ( 404 )
2022-03-10 18:45:02 -06:00
2022-03-20 06:29:08 -05:00
// Check user can access this library item
2024-08-11 16:07:29 -05:00
if ( ! req . user . checkCanAccessLibraryItem ( req . libraryItem ) ) {
2022-03-20 06:29:08 -05:00
return res . sendStatus ( 403 )
}
2023-05-28 12:34:22 -05:00
// For library file routes, get the library file
if ( req . params . fileid ) {
2025-01-02 15:42:52 -06:00
req . libraryFile = req . libraryItem . getLibraryFileWithIno ( req . params . fileid )
2023-05-28 12:34:22 -05: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 07:24:54 -05:00
if ( req . path . includes ( '/play' ) ) {
// allow POST requests using /play and /play/:episodeId
2024-08-11 16:07:29 -05:00
} else if ( req . method == 'DELETE' && ! req . user . canDelete ) {
Logger . warn ( ` [LibraryItemController] User " ${ req . user . username } " attempted to delete without permission ` )
2022-03-12 17:45:32 -06:00
return res . sendStatus ( 403 )
2024-08-11 16:07:29 -05: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-12 17:45:32 -06:00
return res . sendStatus ( 403 )
}
2022-03-10 18:45:02 -06:00
next ( )
}
}
2024-06-22 16:42:13 -05:00
module . exports = new LibraryItemController ( )