diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 5bcdabed..2a68dd63 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -127,7 +127,7 @@ export default { skipMatchingMediaWithIsbn: false, autoScanCronExpression: null, hideSingleBookSeries: false, - metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] + metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] } } }, diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue index 215f79b5..253d4e6b 100644 --- a/client/components/modals/libraries/LibraryScannerSettings.vue +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -64,6 +64,11 @@ export default { name: 'Audio file meta tags', include: true }, + nfoFile: { + id: 'nfoFile', + name: 'NFO file', + include: true + }, txtFiles: { id: 'txtFiles', name: 'desc.txt & reader.txt files', diff --git a/server/objects/settings/LibrarySettings.js b/server/objects/settings/LibrarySettings.js index b734b6bf..10ee19e0 100644 --- a/server/objects/settings/LibrarySettings.js +++ b/server/objects/settings/LibrarySettings.js @@ -9,7 +9,7 @@ class LibrarySettings { this.autoScanCronExpression = null this.audiobooksOnly = false this.hideSingleBookSeries = false // Do not show series that only have 1 book - this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] + this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] if (settings) { this.construct(settings) @@ -28,7 +28,7 @@ class LibrarySettings { this.metadataPrecedence = [...settings.metadataPrecedence] } else { // Added in v2.4.5 - this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] + this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] } } diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 282155f2..48e8529a 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -18,6 +18,7 @@ const BookFinder = require('../finders/BookFinder') const LibraryScan = require("./LibraryScan") const OpfFileScanner = require('./OpfFileScanner') +const NfoFileScanner = require('./NfoFileScanner') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') /** @@ -593,7 +594,7 @@ class BookScanner { } const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) - const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', '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(', ')}]`) for (const metadataSource of metadataPrecedence) { if (bookMetadataSourceHandler[metadataSource]) { @@ -649,6 +650,14 @@ class BookScanner { AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) } + /** + * Metadata from .nfo file + */ + async nfoFile() { + if (!this.libraryItemData.metadataNfoLibraryFile) return + await NfoFileScanner.scanBookNfoFile(this.libraryItemData.metadataNfoLibraryFile, this.bookMetadata) + } + /** * Description from desc.txt and narrator from reader.txt */ diff --git a/server/scanner/LibraryItemScanData.js b/server/scanner/LibraryItemScanData.js index 576280c8..b604e4d7 100644 --- a/server/scanner/LibraryItemScanData.js +++ b/server/scanner/LibraryItemScanData.js @@ -132,6 +132,11 @@ class LibraryItemScanData { return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf') } + /** @type {LibraryItem.LibraryFileObject} */ + get metadataNfoLibraryFile() { + return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo') + } + /** * * @param {LibraryItem} existingLibraryItem diff --git a/server/scanner/NfoFileScanner.js b/server/scanner/NfoFileScanner.js new file mode 100644 index 00000000..e450b5c3 --- /dev/null +++ b/server/scanner/NfoFileScanner.js @@ -0,0 +1,48 @@ +const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata') +const { readTextFile } = require('../utils/fileUtils') + +class NfoFileScanner { + constructor() { } + + /** + * Parse metadata from .nfo file found in library scan and update bookMetadata + * + * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj + * @param {Object} bookMetadata + */ + async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) { + const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path) + const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null + if (nfoMetadata) { + for (const key in nfoMetadata) { + if (key === 'tags') { // Add tags only if tags are empty + if (nfoMetadata.tags.length) { + bookMetadata.tags = nfoMetadata.tags + } + } else if (key === 'genres') { // Add genres only if genres are empty + if (nfoMetadata.genres.length) { + bookMetadata.genres = nfoMetadata.genres + } + } else if (key === 'authors') { + if (nfoMetadata.authors?.length) { + bookMetadata.authors = nfoMetadata.authors + } + } else if (key === 'narrators') { + if (nfoMetadata.narrators?.length) { + bookMetadata.narrators = nfoMetadata.narrators + } + } else if (key === 'series') { + if (nfoMetadata.series) { + bookMetadata.series = [{ + name: nfoMetadata.series, + sequence: nfoMetadata.sequence || null + }] + } + } else if (nfoMetadata[key] && key !== 'sequence') { + bookMetadata[key] = nfoMetadata[key] + } + } + } + } +} +module.exports = new NfoFileScanner() \ No newline at end of file diff --git a/server/utils/parsers/parseNfoMetadata.js b/server/utils/parsers/parseNfoMetadata.js new file mode 100644 index 00000000..a7fbbceb --- /dev/null +++ b/server/utils/parsers/parseNfoMetadata.js @@ -0,0 +1,94 @@ +function parseNfoMetadata(nfoText) { + if (!nfoText) return null + const lines = nfoText.split(/\r?\n/) + const metadata = {} + let insideBookDescription = false + lines.forEach(line => { + if (line.search(/^\s*book description\s*$/i) !== -1) { + insideBookDescription = true + return + } + if (insideBookDescription) { + if (line.search(/^\s*=+\s*$/i) !== -1) return + metadata.description = metadata.description || '' + metadata.description += line + '\n' + return + } + const match = line.match(/^(.*?):(.*)$/) + if (match) { + const key = match[1].toLowerCase().trim() + const value = match[2].trim() + if (!value) return + switch (key) { + case 'title': + { + const titleMatch = value.match(/^(.*?):(.*)$/) + if (titleMatch) { + metadata.title = titleMatch[1].trim() + metadata.subtitle = titleMatch[2].trim() + } else { + metadata.title = value + } + } + break + case 'author': + metadata.authors = value.split(/\s*,\s*/) + break + case 'narrator': + case 'read by': + metadata.narrators = value.split(/\s*,\s*/) + break + case 'series name': + metadata.series = value + break + case 'genre': + metadata.genres = value.split(/\s*,\s*/) + break + case 'tags': + metadata.tags = value.split(/\s*,\s*/) + break + case 'copyright': + case 'audible.com release': + case 'audiobook copyright': + case 'book copyright': + case 'recording copyright': + case 'release date': + case 'date': + { + const year = extractYear(value) + if (year) { + metadata.publishedYear = year + } + } + break; + case 'position in series': + metadata.sequence = value + break + case 'unabridged': + metadata.abridged = value.toLowerCase() === 'yes' ? false : true + break + case 'abridged': + metadata.abridged = value.toLowerCase() === 'no' ? false : true + break + case 'publisher': + metadata.publisher = value + break + case 'asin': + metadata.asin = value + break + case 'isbn': + case 'isbn-10': + case 'isbn-13': + metadata.isbn = value + break + } + } + }) + return metadata +} +module.exports = { parseNfoMetadata } + +function extractYear(str) { + const match = str.match(/\d{4}/g) + return match ? match[match.length-1] : null +} \ No newline at end of file