mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Epub metadata parser and cover extractor #1479
This commit is contained in:
		
							parent
							
								
									48a08e9659
								
							
						
					
					
						commit
						69e23ef9f2
					
				@ -63,7 +63,7 @@ export default {
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
        audioMetatags: {
 | 
					        audioMetatags: {
 | 
				
			||||||
          id: 'audioMetatags',
 | 
					          id: 'audioMetatags',
 | 
				
			||||||
          name: 'Audio file meta tags',
 | 
					          name: 'Audio file meta tags OR ebook metadata',
 | 
				
			||||||
          include: true
 | 
					          include: true
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        nfoFile: {
 | 
					        nfoFile: {
 | 
				
			||||||
 | 
				
			|||||||
@ -7,6 +7,8 @@ const imageType = require('../libs/imageType')
 | 
				
			|||||||
const globals = require('../utils/globals')
 | 
					const globals = require('../utils/globals')
 | 
				
			||||||
const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
 | 
					const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
 | 
				
			||||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
 | 
					const { extractCoverArt } = require('../utils/ffmpegHelpers')
 | 
				
			||||||
 | 
					const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const CacheManager = require('../managers/CacheManager')
 | 
					const CacheManager = require('../managers/CacheManager')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CoverManager {
 | 
					class CoverManager {
 | 
				
			||||||
@ -234,6 +236,7 @@ class CoverManager {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Extract cover art from audio file and save for library item
 | 
					   * Extract cover art from audio file and save for library item
 | 
				
			||||||
 | 
					   * 
 | 
				
			||||||
   * @param {import('../models/Book').AudioFileObject[]} audioFiles 
 | 
					   * @param {import('../models/Book').AudioFileObject[]} audioFiles 
 | 
				
			||||||
   * @param {string} libraryItemId 
 | 
					   * @param {string} libraryItemId 
 | 
				
			||||||
   * @param {string} [libraryItemPath] null for isFile library items 
 | 
					   * @param {string} [libraryItemPath] null for isFile library items 
 | 
				
			||||||
@ -268,6 +271,44 @@ class CoverManager {
 | 
				
			|||||||
    return null
 | 
					    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<string>} 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} url 
 | 
				
			||||||
 | 
				
			|||||||
@ -36,6 +36,8 @@ class AbsMetadataFileScanner {
 | 
				
			|||||||
      for (const key in abMetadata) {
 | 
					      for (const key in abMetadata) {
 | 
				
			||||||
        // TODO: When to override with null or empty arrays?
 | 
					        // TODO: When to override with null or empty arrays?
 | 
				
			||||||
        if (abMetadata[key] === undefined || abMetadata[key] === null) continue
 | 
					        if (abMetadata[key] === undefined || abMetadata[key] === null) continue
 | 
				
			||||||
 | 
					        if (key === 'authors' && !abMetadata.authors?.length) continue
 | 
				
			||||||
 | 
					        if (key === 'genres' && !abMetadata.genres?.length) continue
 | 
				
			||||||
        if (key === 'tags' && !abMetadata.tags?.length) continue
 | 
					        if (key === 'tags' && !abMetadata.tags?.length) continue
 | 
				
			||||||
        if (key === 'chapters' && !abMetadata.chapters?.length) continue
 | 
					        if (key === 'chapters' && !abMetadata.chapters?.length) continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -3,8 +3,8 @@ const Path = require('path')
 | 
				
			|||||||
const sequelize = require('sequelize')
 | 
					const sequelize = require('sequelize')
 | 
				
			||||||
const { LogLevel } = require('../utils/constants')
 | 
					const { LogLevel } = require('../utils/constants')
 | 
				
			||||||
const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
 | 
					const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
 | 
				
			||||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
 | 
					 | 
				
			||||||
const parseNameString = require('../utils/parsers/parseNameString')
 | 
					const parseNameString = require('../utils/parsers/parseNameString')
 | 
				
			||||||
 | 
					const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
 | 
				
			||||||
const globals = require('../utils/globals')
 | 
					const globals = require('../utils/globals')
 | 
				
			||||||
const AudioFileScanner = require('./AudioFileScanner')
 | 
					const AudioFileScanner = require('./AudioFileScanner')
 | 
				
			||||||
const Database = require('../Database')
 | 
					const Database = require('../Database')
 | 
				
			||||||
@ -170,7 +170,9 @@ class BookScanner {
 | 
				
			|||||||
      hasMediaChanges = true
 | 
					      hasMediaChanges = true
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id)
 | 
					    const ebookFileScanData = await parseEbookMetadata.parse(media.ebookFile)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id)
 | 
				
			||||||
    let authorsUpdated = false
 | 
					    let authorsUpdated = false
 | 
				
			||||||
    const bookAuthorsRemoved = []
 | 
					    const bookAuthorsRemoved = []
 | 
				
			||||||
    let seriesUpdated = false
 | 
					    let seriesUpdated = false
 | 
				
			||||||
@ -317,15 +319,26 @@ class BookScanner {
 | 
				
			|||||||
      })
 | 
					      })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // If no cover then extract cover from audio file if available OR search for cover if enabled in server settings
 | 
					    // If no cover then extract cover from audio file OR from ebook
 | 
				
			||||||
    if (!media.coverPath) {
 | 
					 | 
				
			||||||
    const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path
 | 
					    const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path
 | 
				
			||||||
      const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir)
 | 
					    if (!media.coverPath) {
 | 
				
			||||||
 | 
					      let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir)
 | 
				
			||||||
      if (extractedCoverPath) {
 | 
					      if (extractedCoverPath) {
 | 
				
			||||||
        libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
 | 
					        libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
 | 
				
			||||||
        media.coverPath = extractedCoverPath
 | 
					        media.coverPath = extractedCoverPath
 | 
				
			||||||
        hasMediaChanges = true
 | 
					        hasMediaChanges = true
 | 
				
			||||||
      } else if (Database.serverSettings.scannerFindCovers) {
 | 
					      } else if (ebookFileScanData?.ebookCoverPath) {
 | 
				
			||||||
 | 
					        extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, existingLibraryItem.id, libraryItemDir)
 | 
				
			||||||
 | 
					        if (extractedCoverPath) {
 | 
				
			||||||
 | 
					          libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from ebook file to path "${extractedCoverPath}"`)
 | 
				
			||||||
 | 
					          media.coverPath = extractedCoverPath
 | 
				
			||||||
 | 
					          hasMediaChanges = true
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If no cover then search for cover if enabled in server settings
 | 
				
			||||||
 | 
					    if (!media.coverPath && Database.serverSettings.scannerFindCovers) {
 | 
				
			||||||
      const authorName = media.authors.map(au => au.name).filter(au => au).join(', ')
 | 
					      const authorName = media.authors.map(au => au.name).filter(au => au).join(', ')
 | 
				
			||||||
      const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan)
 | 
					      const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan)
 | 
				
			||||||
      if (coverPath) {
 | 
					      if (coverPath) {
 | 
				
			||||||
@ -333,7 +346,6 @@ class BookScanner {
 | 
				
			|||||||
        hasMediaChanges = true
 | 
					        hasMediaChanges = true
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    existingLibraryItem.media = media
 | 
					    existingLibraryItem.media = media
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -408,12 +420,14 @@ class BookScanner {
 | 
				
			|||||||
      return null
 | 
					      return null
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let ebookFileScanData = null
 | 
				
			||||||
    if (ebookLibraryFile) {
 | 
					    if (ebookLibraryFile) {
 | 
				
			||||||
      ebookLibraryFile = ebookLibraryFile.toJSON()
 | 
					      ebookLibraryFile = ebookLibraryFile.toJSON()
 | 
				
			||||||
      ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
 | 
					      ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
 | 
				
			||||||
 | 
					      ebookFileScanData = await parseEbookMetadata.parse(ebookLibraryFile)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan, librarySettings)
 | 
					    const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings)
 | 
				
			||||||
    bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean
 | 
					    bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean
 | 
				
			||||||
    bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean
 | 
					    bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -481,18 +495,27 @@ class BookScanner {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // If cover was not found in folder then check embedded covers in audio files OR search for cover
 | 
					    // If cover was not found in folder then check embedded covers in audio files OR ebook file
 | 
				
			||||||
    if (!bookObject.coverPath) {
 | 
					 | 
				
			||||||
    const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path
 | 
					    const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path
 | 
				
			||||||
      // Extract and save embedded cover art
 | 
					    if (!bookObject.coverPath) {
 | 
				
			||||||
      const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir)
 | 
					      let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir)
 | 
				
			||||||
      if (extractedCoverPath) {
 | 
					      if (extractedCoverPath) {
 | 
				
			||||||
 | 
					        libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from audio file at "${extractedCoverPath}" for book "${bookObject.title}"`)
 | 
				
			||||||
        bookObject.coverPath = extractedCoverPath
 | 
					        bookObject.coverPath = extractedCoverPath
 | 
				
			||||||
      } else if (Database.serverSettings.scannerFindCovers) {
 | 
					      } else if (ebookFileScanData?.ebookCoverPath) {
 | 
				
			||||||
 | 
					        extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, libraryItemObj.id, libraryItemDir)
 | 
				
			||||||
 | 
					        if (extractedCoverPath) {
 | 
				
			||||||
 | 
					          libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from ebook file at "${extractedCoverPath}" for book "${bookObject.title}"`)
 | 
				
			||||||
 | 
					          bookObject.coverPath = extractedCoverPath
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If cover not found then search for cover if enabled in settings
 | 
				
			||||||
 | 
					    if (!bookObject.coverPath && Database.serverSettings.scannerFindCovers) {
 | 
				
			||||||
      const authorName = bookMetadata.authors.join(', ')
 | 
					      const authorName = bookMetadata.authors.join(', ')
 | 
				
			||||||
      bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan)
 | 
					      bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    libraryItemObj.book = bookObject
 | 
					    libraryItemObj.book = bookObject
 | 
				
			||||||
    const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {
 | 
					    const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {
 | 
				
			||||||
@ -570,13 +593,14 @@ class BookScanner {
 | 
				
			|||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * 
 | 
					   * 
 | 
				
			||||||
   * @param {import('../models/Book').AudioFileObject[]} audioFiles 
 | 
					   * @param {import('../models/Book').AudioFileObject[]} audioFiles 
 | 
				
			||||||
 | 
					   * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
 | 
				
			||||||
   * @param {import('./LibraryItemScanData')} libraryItemData 
 | 
					   * @param {import('./LibraryItemScanData')} libraryItemData 
 | 
				
			||||||
   * @param {LibraryScan} libraryScan 
 | 
					   * @param {LibraryScan} libraryScan 
 | 
				
			||||||
   * @param {import('../models/Library').LibrarySettingsObject} librarySettings
 | 
					   * @param {import('../models/Library').LibrarySettingsObject} librarySettings
 | 
				
			||||||
   * @param {string} [existingLibraryItemId]
 | 
					   * @param {string} [existingLibraryItemId]
 | 
				
			||||||
   * @returns {Promise<BookMetadataObject>}
 | 
					   * @returns {Promise<BookMetadataObject>}
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) {
 | 
					  async getBookMetadataFromScanData(audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) {
 | 
				
			||||||
    // First set book metadata from folder/file names
 | 
					    // First set book metadata from folder/file names
 | 
				
			||||||
    const bookMetadata = {
 | 
					    const bookMetadata = {
 | 
				
			||||||
      title: libraryItemData.mediaMetadata.title, // required
 | 
					      title: libraryItemData.mediaMetadata.title, // required
 | 
				
			||||||
@ -599,7 +623,7 @@ class BookScanner {
 | 
				
			|||||||
      coverPath: undefined
 | 
					      coverPath: undefined
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId)
 | 
					    const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId)
 | 
				
			||||||
    const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
 | 
					    const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
 | 
				
			||||||
    libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`)
 | 
					    libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`)
 | 
				
			||||||
    for (const metadataSource of metadataPrecedence) {
 | 
					    for (const metadataSource of metadataPrecedence) {
 | 
				
			||||||
@ -627,13 +651,15 @@ class BookScanner {
 | 
				
			|||||||
     * 
 | 
					     * 
 | 
				
			||||||
     * @param {Object} bookMetadata 
 | 
					     * @param {Object} bookMetadata 
 | 
				
			||||||
     * @param {import('../models/Book').AudioFileObject[]} audioFiles 
 | 
					     * @param {import('../models/Book').AudioFileObject[]} audioFiles 
 | 
				
			||||||
 | 
					     * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData
 | 
				
			||||||
     * @param {import('./LibraryItemScanData')} libraryItemData 
 | 
					     * @param {import('./LibraryItemScanData')} libraryItemData 
 | 
				
			||||||
     * @param {LibraryScan} libraryScan 
 | 
					     * @param {LibraryScan} libraryScan 
 | 
				
			||||||
     * @param {string} existingLibraryItemId 
 | 
					     * @param {string} existingLibraryItemId 
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    constructor(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) {
 | 
					    constructor(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) {
 | 
				
			||||||
      this.bookMetadata = bookMetadata
 | 
					      this.bookMetadata = bookMetadata
 | 
				
			||||||
      this.audioFiles = audioFiles
 | 
					      this.audioFiles = audioFiles
 | 
				
			||||||
 | 
					      this.ebookFileScanData = ebookFileScanData
 | 
				
			||||||
      this.libraryItemData = libraryItemData
 | 
					      this.libraryItemData = libraryItemData
 | 
				
			||||||
      this.libraryScan = libraryScan
 | 
					      this.libraryScan = libraryScan
 | 
				
			||||||
      this.existingLibraryItemId = existingLibraryItemId
 | 
					      this.existingLibraryItemId = existingLibraryItemId
 | 
				
			||||||
@ -647,13 +673,42 @@ class BookScanner {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Metadata from audio file meta tags
 | 
					     * Metadata from audio file meta tags OR metadata from ebook file
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    audioMetatags() {
 | 
					    audioMetatags() {
 | 
				
			||||||
      if (!this.audioFiles.length) return
 | 
					      if (this.audioFiles.length) {
 | 
				
			||||||
        // Modifies bookMetadata with metadata mapped from audio file meta tags
 | 
					        // Modifies bookMetadata with metadata mapped from audio file meta tags
 | 
				
			||||||
        const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title
 | 
					        const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title
 | 
				
			||||||
        AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
 | 
					        AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
 | 
				
			||||||
 | 
					      } else if (this.ebookFileScanData) {
 | 
				
			||||||
 | 
					        const ebookMetdataObject = this.ebookFileScanData.metadata
 | 
				
			||||||
 | 
					        for (const key in ebookMetdataObject) {
 | 
				
			||||||
 | 
					          if (key === 'tags') {
 | 
				
			||||||
 | 
					            if (ebookMetdataObject.tags.length) {
 | 
				
			||||||
 | 
					              this.bookMetadata.tags = ebookMetdataObject.tags
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          } else if (key === 'genres') {
 | 
				
			||||||
 | 
					            if (ebookMetdataObject.genres.length) {
 | 
				
			||||||
 | 
					              this.bookMetadata.genres = ebookMetdataObject.genres
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          } else if (key === 'authors') {
 | 
				
			||||||
 | 
					            if (ebookMetdataObject.authors?.length) {
 | 
				
			||||||
 | 
					              this.bookMetadata.authors = ebookMetdataObject.authors
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          } else if (key === 'narrators') {
 | 
				
			||||||
 | 
					            if (ebookMetdataObject.narrators?.length) {
 | 
				
			||||||
 | 
					              this.bookMetadata.narrators = ebookMetdataObject.narrators
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          } else if (key === 'series') {
 | 
				
			||||||
 | 
					            if (ebookMetdataObject.series?.length) {
 | 
				
			||||||
 | 
					              this.bookMetadata.series = ebookMetdataObject.series
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          } else if (ebookMetdataObject[key] && key !== 'sequence') {
 | 
				
			||||||
 | 
					            this.bookMetadata[key] = ebookMetdataObject[key]
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return null
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,6 @@ const uuidv4 = require("uuid").v4
 | 
				
			|||||||
const Path = require('path')
 | 
					const Path = require('path')
 | 
				
			||||||
const { LogLevel } = require('../utils/constants')
 | 
					const { LogLevel } = require('../utils/constants')
 | 
				
			||||||
const { getTitleIgnorePrefix } = require('../utils/index')
 | 
					const { getTitleIgnorePrefix } = require('../utils/index')
 | 
				
			||||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
 | 
					 | 
				
			||||||
const AudioFileScanner = require('./AudioFileScanner')
 | 
					const AudioFileScanner = require('./AudioFileScanner')
 | 
				
			||||||
const Database = require('../Database')
 | 
					const Database = require('../Database')
 | 
				
			||||||
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
 | 
					const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										42
									
								
								server/utils/parsers/parseEbookMetadata.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								server/utils/parsers/parseEbookMetadata.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					const parseEpubMetadata = require('./parseEpubMetadata')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * @typedef EBookFileScanData
 | 
				
			||||||
 | 
					 * @property {string} path
 | 
				
			||||||
 | 
					 * @property {string} ebookFormat
 | 
				
			||||||
 | 
					 * @property {string} ebookCoverPath internal image path
 | 
				
			||||||
 | 
					 * @property {import('../../scanner/BookScanner').BookMetadataObject} metadata
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Parse metadata from ebook file
 | 
				
			||||||
 | 
					 * 
 | 
				
			||||||
 | 
					 * @param {import('../../models/Book').EBookFileObject} ebookFile 
 | 
				
			||||||
 | 
					 * @returns {Promise<EBookFileScanData>}
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					async function parse(ebookFile) {
 | 
				
			||||||
 | 
					  if (!ebookFile) return null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (ebookFile.ebookFormat === 'epub') {
 | 
				
			||||||
 | 
					    return parseEpubMetadata.parse(ebookFile.metadata.path)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return null
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					module.exports.parse = parse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Extract cover from ebook file
 | 
				
			||||||
 | 
					 * 
 | 
				
			||||||
 | 
					 * @param {EBookFileScanData} ebookFileScanData 
 | 
				
			||||||
 | 
					 * @param {string} outputCoverPath 
 | 
				
			||||||
 | 
					 * @returns {Promise<boolean>}
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					async function extractCoverImage(ebookFileScanData, outputCoverPath) {
 | 
				
			||||||
 | 
					  if (!ebookFileScanData?.ebookCoverPath) return false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (ebookFileScanData.ebookFormat === 'epub') {
 | 
				
			||||||
 | 
					    return parseEpubMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return false
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					module.exports.extractCoverImage = extractCoverImage
 | 
				
			||||||
							
								
								
									
										109
									
								
								server/utils/parsers/parseEpubMetadata.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								server/utils/parsers/parseEpubMetadata.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,109 @@
 | 
				
			|||||||
 | 
					const Path = require('path')
 | 
				
			||||||
 | 
					const Logger = require('../../Logger')
 | 
				
			||||||
 | 
					const StreamZip = require('../../libs/nodeStreamZip')
 | 
				
			||||||
 | 
					const parseOpfMetadata = require('./parseOpfMetadata')
 | 
				
			||||||
 | 
					const { xmlToJSON } = require('../index')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Extract file from epub and return string content
 | 
				
			||||||
 | 
					 * 
 | 
				
			||||||
 | 
					 * @param {string} epubPath 
 | 
				
			||||||
 | 
					 * @param {string} filepath 
 | 
				
			||||||
 | 
					 * @returns {Promise<string>}
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					async function extractFileFromEpub(epubPath, filepath) {
 | 
				
			||||||
 | 
					  const zip = new StreamZip.async({ file: epubPath })
 | 
				
			||||||
 | 
					  const data = await zip.entryData(filepath).catch((error) => {
 | 
				
			||||||
 | 
					    Logger.error(`[parseEpubMetadata] Failed to extract ${filepath} from epub at "${epubPath}"`, error)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  const filedata = data?.toString('utf8')
 | 
				
			||||||
 | 
					  await zip.close()
 | 
				
			||||||
 | 
					  return filedata
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Extract an XML file from epub and return JSON
 | 
				
			||||||
 | 
					 * 
 | 
				
			||||||
 | 
					 * @param {string} epubPath 
 | 
				
			||||||
 | 
					 * @param {string} xmlFilepath 
 | 
				
			||||||
 | 
					 * @returns {Promise<Object>}
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					async function extractXmlToJson(epubPath, xmlFilepath) {
 | 
				
			||||||
 | 
					  const filedata = await extractFileFromEpub(epubPath, xmlFilepath)
 | 
				
			||||||
 | 
					  if (!filedata) return null
 | 
				
			||||||
 | 
					  return xmlToJSON(filedata)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Extract cover image from epub return true if success
 | 
				
			||||||
 | 
					 * 
 | 
				
			||||||
 | 
					 * @param {string} epubPath 
 | 
				
			||||||
 | 
					 * @param {string} epubImageFilepath 
 | 
				
			||||||
 | 
					 * @param {string} outputCoverPath 
 | 
				
			||||||
 | 
					 * @returns {Promise<boolean>}
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) {
 | 
				
			||||||
 | 
					  const zip = new StreamZip.async({ file: epubPath })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const success = await zip.extract(epubImageFilepath, outputCoverPath).then(() => true).catch((error) => {
 | 
				
			||||||
 | 
					    Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error)
 | 
				
			||||||
 | 
					    return false
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await zip.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return success
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					module.exports.extractCoverImage = extractCoverImage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Parse metadata from epub
 | 
				
			||||||
 | 
					 * 
 | 
				
			||||||
 | 
					 * @param {string} epubPath 
 | 
				
			||||||
 | 
					 * @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					async function parse(epubPath) {
 | 
				
			||||||
 | 
					  Logger.debug(`Parsing metadata from epub at "${epubPath}"`)
 | 
				
			||||||
 | 
					  // Entrypoint of the epub that contains the filepath to the package document (opf file)
 | 
				
			||||||
 | 
					  const containerJson = await extractXmlToJson(epubPath, 'META-INF/container.xml')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Get package document opf filepath from container.xml
 | 
				
			||||||
 | 
					  const packageDocPath = containerJson.container?.rootfiles?.[0]?.rootfile?.[0]?.$?.['full-path']
 | 
				
			||||||
 | 
					  if (!packageDocPath) {
 | 
				
			||||||
 | 
					    Logger.error(`Failed to get package doc path in Container.xml`, JSON.stringify(containerJson, null, 2))
 | 
				
			||||||
 | 
					    return null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Extract package document to JSON
 | 
				
			||||||
 | 
					  const packageJson = await extractXmlToJson(epubPath, packageDocPath)
 | 
				
			||||||
 | 
					  if (!packageJson) {
 | 
				
			||||||
 | 
					    return null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Parse metadata from package document opf file
 | 
				
			||||||
 | 
					  const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(packageJson)
 | 
				
			||||||
 | 
					  if (!opfMetadata) {
 | 
				
			||||||
 | 
					    Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2))
 | 
				
			||||||
 | 
					    return null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const payload = {
 | 
				
			||||||
 | 
					    path: epubPath,
 | 
				
			||||||
 | 
					    ebookFormat: 'epub',
 | 
				
			||||||
 | 
					    metadata: opfMetadata
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Attempt to find filepath to cover image
 | 
				
			||||||
 | 
					  const manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find(item => item.$?.['media-type']?.startsWith('image/'))
 | 
				
			||||||
 | 
					  let coverImagePath = manifestFirstImage?.$?.href
 | 
				
			||||||
 | 
					  if (coverImagePath) {
 | 
				
			||||||
 | 
					    const packageDirname = Path.dirname(packageDocPath)
 | 
				
			||||||
 | 
					    payload.ebookCoverPath = Path.posix.join(packageDirname, coverImagePath)
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    Logger.warn(`Cover image not found in manifest for epub at "${epubPath}"`)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return payload
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					module.exports.parse = parse
 | 
				
			||||||
@ -136,11 +136,7 @@ function stripPrefix(str) {
 | 
				
			|||||||
  return str.split(':').pop()
 | 
					  return str.split(':').pop()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports.parseOpfMetadataXML = async (xml) => {
 | 
					module.exports.parseOpfMetadataJson = (json) => {
 | 
				
			||||||
  const json = await xmlToJSON(xml)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!json) return null
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Handle <package ...> or with prefix <ns0:package ...>
 | 
					  // Handle <package ...> or with prefix <ns0:package ...>
 | 
				
			||||||
  const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package')
 | 
					  const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package')
 | 
				
			||||||
  if (!packageKey) return null
 | 
					  if (!packageKey) return null
 | 
				
			||||||
@ -167,7 +163,7 @@ module.exports.parseOpfMetadataXML = async (xml) => {
 | 
				
			|||||||
  const creators = parseCreators(metadata)
 | 
					  const creators = parseCreators(metadata)
 | 
				
			||||||
  const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au)
 | 
					  const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au)
 | 
				
			||||||
  const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt)
 | 
					  const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt)
 | 
				
			||||||
  const data = {
 | 
					  return {
 | 
				
			||||||
    title: fetchTitle(metadata),
 | 
					    title: fetchTitle(metadata),
 | 
				
			||||||
    subtitle: fetchSubtitle(metadata),
 | 
					    subtitle: fetchSubtitle(metadata),
 | 
				
			||||||
    authors,
 | 
					    authors,
 | 
				
			||||||
@ -182,5 +178,10 @@ module.exports.parseOpfMetadataXML = async (xml) => {
 | 
				
			|||||||
    series: fetchSeries(metadataMeta),
 | 
					    series: fetchSeries(metadataMeta),
 | 
				
			||||||
    tags: fetchTags(metadata)
 | 
					    tags: fetchTags(metadata)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return data
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports.parseOpfMetadataXML = async (xml) => {
 | 
				
			||||||
 | 
					  const json = await xmlToJSON(xml)
 | 
				
			||||||
 | 
					  if (!json) return null
 | 
				
			||||||
 | 
					  return this.parseOpfMetadataJson(json)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user