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: { | ||||
|           id: 'audioMetatags', | ||||
|           name: 'Audio file meta tags', | ||||
|           name: 'Audio file meta tags OR ebook metadata', | ||||
|           include: true | ||||
|         }, | ||||
|         nfoFile: { | ||||
|  | ||||
| @ -7,6 +7,8 @@ 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 { | ||||
| @ -234,6 +236,7 @@ class CoverManager { | ||||
| 
 | ||||
|   /** | ||||
|    * 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  | ||||
| @ -268,6 +271,44 @@ class CoverManager { | ||||
|     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  | ||||
|  | ||||
| @ -36,6 +36,8 @@ class AbsMetadataFileScanner { | ||||
|       for (const key in abMetadata) { | ||||
|         // TODO: When to override with null or empty arrays?
 | ||||
|         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 === 'chapters' && !abMetadata.chapters?.length) continue | ||||
| 
 | ||||
|  | ||||
| @ -3,8 +3,8 @@ const Path = require('path') | ||||
| const sequelize = require('sequelize') | ||||
| const { LogLevel } = require('../utils/constants') | ||||
| const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index') | ||||
| const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') | ||||
| const parseNameString = require('../utils/parsers/parseNameString') | ||||
| const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') | ||||
| const globals = require('../utils/globals') | ||||
| const AudioFileScanner = require('./AudioFileScanner') | ||||
| const Database = require('../Database') | ||||
| @ -170,7 +170,9 @@ class BookScanner { | ||||
|       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 | ||||
|     const bookAuthorsRemoved = [] | ||||
|     let seriesUpdated = false | ||||
| @ -317,24 +319,34 @@ 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
 | ||||
|     const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path | ||||
|     if (!media.coverPath) { | ||||
|       const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path | ||||
|       const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir) | ||||
|       let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir) | ||||
|       if (extractedCoverPath) { | ||||
|         libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`) | ||||
|         media.coverPath = extractedCoverPath | ||||
|         hasMediaChanges = true | ||||
|       } else if (Database.serverSettings.scannerFindCovers) { | ||||
|         const authorName = media.authors.map(au => au.name).filter(au => au).join(', ') | ||||
|         const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) | ||||
|         if (coverPath) { | ||||
|           media.coverPath = coverPath | ||||
|       } 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 coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) | ||||
|       if (coverPath) { | ||||
|         media.coverPath = coverPath | ||||
|         hasMediaChanges = true | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     existingLibraryItem.media = media | ||||
| 
 | ||||
|     let libraryItemUpdated = false | ||||
| @ -408,12 +420,14 @@ class BookScanner { | ||||
|       return null | ||||
|     } | ||||
| 
 | ||||
|     let ebookFileScanData = null | ||||
|     if (ebookLibraryFile) { | ||||
|       ebookLibraryFile = ebookLibraryFile.toJSON() | ||||
|       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.abridged = !!bookMetadata.abridged // Ensure boolean
 | ||||
| 
 | ||||
| @ -481,19 +495,28 @@ 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
 | ||||
|     const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path | ||||
|     if (!bookObject.coverPath) { | ||||
|       const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path | ||||
|       // Extract and save embedded cover art
 | ||||
|       const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir) | ||||
|       let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir) | ||||
|       if (extractedCoverPath) { | ||||
|         libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from audio file at "${extractedCoverPath}" for book "${bookObject.title}"`) | ||||
|         bookObject.coverPath = extractedCoverPath | ||||
|       } else if (Database.serverSettings.scannerFindCovers) { | ||||
|         const authorName = bookMetadata.authors.join(', ') | ||||
|         bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) | ||||
|       } 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(', ') | ||||
|       bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) | ||||
|     } | ||||
| 
 | ||||
|     libraryItemObj.book = bookObject | ||||
|     const libraryItem = await Database.libraryItemModel.create(libraryItemObj, { | ||||
|       include: { | ||||
| @ -570,13 +593,14 @@ class BookScanner { | ||||
|   /** | ||||
|    *  | ||||
|    * @param {import('../models/Book').AudioFileObject[]} audioFiles  | ||||
|    * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData | ||||
|    * @param {import('./LibraryItemScanData')} libraryItemData  | ||||
|    * @param {LibraryScan} libraryScan  | ||||
|    * @param {import('../models/Library').LibrarySettingsObject} librarySettings | ||||
|    * @param {string} [existingLibraryItemId] | ||||
|    * @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
 | ||||
|     const bookMetadata = { | ||||
|       title: libraryItemData.mediaMetadata.title, // required
 | ||||
| @ -599,7 +623,7 @@ class BookScanner { | ||||
|       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'] | ||||
|     libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) | ||||
|     for (const metadataSource of metadataPrecedence) { | ||||
| @ -627,13 +651,15 @@ class BookScanner { | ||||
|      *  | ||||
|      * @param {Object} bookMetadata  | ||||
|      * @param {import('../models/Book').AudioFileObject[]} audioFiles  | ||||
|      * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData | ||||
|      * @param {import('./LibraryItemScanData')} libraryItemData  | ||||
|      * @param {LibraryScan} libraryScan  | ||||
|      * @param {string} existingLibraryItemId  | ||||
|      */ | ||||
|     constructor(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) { | ||||
|     constructor(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) { | ||||
|       this.bookMetadata = bookMetadata | ||||
|       this.audioFiles = audioFiles | ||||
|       this.ebookFileScanData = ebookFileScanData | ||||
|       this.libraryItemData = libraryItemData | ||||
|       this.libraryScan = libraryScan | ||||
|       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() { | ||||
|       if (!this.audioFiles.length) return | ||||
|       // Modifies bookMetadata with metadata mapped from audio file meta tags
 | ||||
|       const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title | ||||
|       AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) | ||||
|       if (this.audioFiles.length) { | ||||
|         // Modifies bookMetadata with metadata mapped from audio file meta tags
 | ||||
|         const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title | ||||
|         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 { LogLevel } = require('../utils/constants') | ||||
| const { getTitleIgnorePrefix } = require('../utils/index') | ||||
| const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') | ||||
| const AudioFileScanner = require('./AudioFileScanner') | ||||
| const Database = require('../Database') | ||||
| 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() | ||||
| } | ||||
| 
 | ||||
| module.exports.parseOpfMetadataXML = async (xml) => { | ||||
|   const json = await xmlToJSON(xml) | ||||
| 
 | ||||
|   if (!json) return null | ||||
| 
 | ||||
| module.exports.parseOpfMetadataJson = (json) => { | ||||
|   // Handle <package ...> or with prefix <ns0:package ...>
 | ||||
|   const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package') | ||||
|   if (!packageKey) return null | ||||
| @ -167,7 +163,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { | ||||
|   const creators = parseCreators(metadata) | ||||
|   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 data = { | ||||
|   return { | ||||
|     title: fetchTitle(metadata), | ||||
|     subtitle: fetchSubtitle(metadata), | ||||
|     authors, | ||||
| @ -182,5 +178,10 @@ module.exports.parseOpfMetadataXML = async (xml) => { | ||||
|     series: fetchSeries(metadataMeta), | ||||
|     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