const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') const readChunk = require('../libs/readChunk') const imageType = require('../libs/imageType') const globals = require('../utils/globals') const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { extractCoverArt } = require('../utils/ffmpegHelpers') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const CacheManager = require('../managers/CacheManager') class CoverManager { constructor() { } getCoverDirectory(libraryItem) { if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) { return libraryItem.path } else { return Path.posix.join(Path.posix.join(global.MetadataPath, 'items'), libraryItem.id) } } getFilesInDirectory(dir) { try { return fs.readdir(dir) } catch (error) { Logger.error(`[CoverManager] Failed to get files in dir ${dir}`, error) return [] } } removeFile(filepath) { try { return fs.pathExists(filepath).then((exists) => { if (!exists) Logger.warn(`[CoverManager] Attempting to remove file that does not exist ${filepath}`) return exists ? fs.unlink(filepath) : false }) } catch (error) { Logger.error(`[CoverManager] Failed to remove file "${filepath}"`, error) return false } } // Remove covers that dont have the same filename as the new cover async removeOldCovers(dirpath, newCoverExt) { var filesInDir = await this.getFilesInDirectory(dirpath) const imageExtensions = ['.jpeg', '.jpg', '.png', '.webp', '.jiff'] for (let i = 0; i < filesInDir.length; i++) { var file = filesInDir[i] var _extname = Path.extname(file).toLowerCase() var _filename = Path.basename(file, _extname).toLowerCase() if (_filename === 'cover' && _extname !== newCoverExt && imageExtensions.includes(_extname)) { var filepath = Path.join(dirpath, file) Logger.debug(`[CoverManager] Removing old cover from metadata "${filepath}"`) await this.removeFile(filepath) } } } async checkFileIsValidImage(imagepath, removeOnInvalid = false) { const buffer = await readChunk(imagepath, 0, 12) const imgType = imageType(buffer) if (!imgType) { if (removeOnInvalid) await this.removeFile(imagepath) return { error: 'Invalid image' } } if (!globals.SupportedImageTypes.includes(imgType.ext)) { if (removeOnInvalid) await this.removeFile(imagepath) return { error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})` } } return imgType } async uploadCover(libraryItem, coverFile) { const extname = Path.extname(coverFile.name.toLowerCase()) if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) { return { error: `Invalid image type ${extname} (Supported: ${globals.SupportedImageTypes.join(',')})` } } const coverDirPath = this.getCoverDirectory(libraryItem) await fs.ensureDir(coverDirPath) const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) // Move cover from temp upload dir to destination const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { Logger.error('[CoverManager] Failed to move cover file', path, error) return false }) if (!success) { return { error: 'Failed to move cover into destination' } } await this.removeOldCovers(coverDirPath, extname) await CacheManager.purgeCoverCache(libraryItem.id) Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`) libraryItem.updateMediaCover(coverFullPath) return { cover: coverFullPath } } async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) { try { // Force save cover with library item is used for adding new podcasts var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem) await fs.ensureDir(coverDirPath) var temppath = Path.posix.join(coverDirPath, 'cover') let errorMsg = '' let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { errorMsg = err.message || 'Unknown error' Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) return false }) if (!success) { return { error: 'Failed to download image from url: ' + errorMsg } } var imgtype = await this.checkFileIsValidImage(temppath, true) if (imgtype.error) { return imgtype } var coverFilename = `cover.${imgtype.ext}` var coverFullPath = Path.posix.join(coverDirPath, coverFilename) await fs.rename(temppath, coverFullPath) await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) await CacheManager.purgeCoverCache(libraryItem.id) Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`) libraryItem.updateMediaCover(coverFullPath) return { cover: coverFullPath } } catch (error) { Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error) return { error: 'Failed to fetch image from url' } } } async validateCoverPath(coverPath, libraryItem) { // Invalid cover path if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) { Logger.error(`[CoverManager] validate cover path invalid http url "${coverPath}"`) return { error: 'Invalid cover path' } } coverPath = filePathToPOSIX(coverPath) // Cover path already set on media if (libraryItem.media.coverPath == coverPath) { Logger.debug(`[CoverManager] validate cover path already set "${coverPath}"`) return { cover: coverPath, updated: false } } // Cover path does not exist if (!await fs.pathExists(coverPath)) { Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`) return { error: 'Cover path does not exist' } } // Cover path is not a file if (!await checkPathIsFile(coverPath)) { Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`) return { error: 'Cover path is not a file' } } // Check valid image at path var imgtype = await this.checkFileIsValidImage(coverPath, false) if (imgtype.error) { return imgtype } var coverDirPath = this.getCoverDirectory(libraryItem) // Cover path is not in correct directory - make a copy if (!coverPath.startsWith(coverDirPath)) { await fs.ensureDir(coverDirPath) var coverFilename = `cover.${imgtype.ext}` var newCoverPath = Path.posix.join(coverDirPath, coverFilename) Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`) var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => { Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) return false }) if (!copySuccess) { return { error: 'Failed to copy cover to dir' } } await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) Logger.debug(`[CoverManager] cover copy success`) coverPath = newCoverPath } await CacheManager.purgeCoverCache(libraryItem.id) libraryItem.updateMediaCover(coverPath) return { cover: coverPath, updated: true } } /** * Extract cover art from audio file and save for library item * * @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {string} libraryItemId * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) { let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt) if (!audioFileWithCover) return null let coverDirPath = null if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { coverDirPath = libraryItemPath } else { coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) } await fs.ensureDir(coverDirPath) const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg' const coverFilePath = Path.join(coverDirPath, coverFilename) const coverAlreadyExists = await fs.pathExists(coverFilePath) if (coverAlreadyExists) { Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${coverFilePath}" - bail`) return null } const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath) if (success) { await CacheManager.purgeCoverCache(libraryItemId) return coverFilePath } return null } /** * Extract cover art from ebook and save for library item * * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData * @param {string} libraryItemId * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) { if (!ebookFileScanData?.ebookCoverPath) return null let coverDirPath = null if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { coverDirPath = libraryItemPath } else { coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) } await fs.ensureDir(coverDirPath) let extname = Path.extname(ebookFileScanData.ebookCoverPath) || '.jpg' if (extname === '.jpeg') extname = '.jpg' const coverFilename = `cover${extname}` const coverFilePath = Path.join(coverDirPath, coverFilename) // TODO: Overwrite if exists? const coverAlreadyExists = await fs.pathExists(coverFilePath) if (coverAlreadyExists) { Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${coverFilePath}" - overwriting`) } const success = await parseEbookMetadata.extractCoverImage(ebookFileScanData, coverFilePath) if (success) { await CacheManager.purgeCoverCache(libraryItemId) return coverFilePath } return null } /** * * @param {string} url * @param {string} libraryItemId * @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast * @returns {Promise<{error:string}|{cover:string}>} */ async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) { try { let coverDirPath = null if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { coverDirPath = libraryItemPath } else { coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) } await fs.ensureDir(coverDirPath) const temppath = Path.posix.join(coverDirPath, 'cover') const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) return false }) if (!success) { return { error: 'Failed to download image from url' } } const imgtype = await this.checkFileIsValidImage(temppath, true) if (imgtype.error) { return imgtype } const coverFullPath = Path.posix.join(coverDirPath, `cover.${imgtype.ext}`) await fs.rename(temppath, coverFullPath) await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) await CacheManager.purgeCoverCache(libraryItemId) Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}"`) return { cover: coverFullPath } } catch (error) { Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error) return { error: 'Failed to fetch image from url' } } } } module.exports = new CoverManager()