From 10d9e1138798463604d396d0eff9b5f8e3912243 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 12 Apr 2022 16:05:16 -0500 Subject: [PATCH] Update abmetadata file for new data model, add chapter and description section parser --- client/pages/config/backups.vue | 2 +- client/pages/config/index.vue | 20 +- server/Db.js | 3 - server/Server.js | 2 +- server/managers/CoverManager.js | 2 +- server/objects/LibraryItem.js | 27 ++ server/objects/ServerSettings.js | 21 +- server/objects/mediaTypes/Book.js | 14 +- server/objects/mediaTypes/Podcast.js | 17 ++ server/scanner/ScanOptions.js | 6 +- server/scanner/Scanner.js | 7 +- server/utils/abmetadataGenerator.js | 391 ++++++++++++++++++++++++--- 12 files changed, 439 insertions(+), 73 deletions(-) diff --git a/client/pages/config/backups.vue b/client/pages/config/backups.vue index 2e4aa6d3..f6adb3f2 100644 --- a/client/pages/config/backups.vue +++ b/client/pages/config/backups.vue @@ -5,7 +5,7 @@

Backups

-

Backups include users, user progress, book details, server settings and covers stored in /metadata/books.
Backups do not include any files stored in your library folders.

+

Backups include users, user progress, book details, server settings and covers stored in /metadata/items.
Backups do not include any files stored in your library folders.

diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index fd413136..373eae48 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -8,20 +8,20 @@
- - + +

- Store covers with book + Store covers with item info_outlined

- - + +

- Store metadata with book + Store metadata with item info_outlined

@@ -47,10 +47,6 @@
-
@@ -218,8 +214,8 @@ export default { sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"', scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.
Note: This will extend scan time', bookshelfView: 'Alternative bookshelf view that shows title & author under book covers', - storeCoverWithBook: 'By default covers are stored in /metadata/books, enabling this setting will store covers in the books folder. Only one file named "cover" will be kept', - storeMetadataWithBook: 'By default metadata files are stored in /metadata/books, enabling this setting will store metadata files in the books folder. Uses .abs file extension', + storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept', + storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension', coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers' }, showConfirmPurgeCache: false diff --git a/server/Db.js b/server/Db.js index fed3981a..62e1bb0f 100644 --- a/server/Db.js +++ b/server/Db.js @@ -1,11 +1,8 @@ const Path = require('path') -// const njodb = require("njodb") const njodb = require('./njodb') -const fs = require('fs-extra') const jwt = require('jsonwebtoken') const Logger = require('./Logger') const { version } = require('../package.json') -// const Audiobook = require('./objects/Audiobook') const LibraryItem = require('./objects/LibraryItem') const User = require('./objects/user/User') const UserCollection = require('./objects/UserCollection') diff --git a/server/Server.js b/server/Server.js index b6d5f3db..fe6c3241 100644 --- a/server/Server.js +++ b/server/Server.js @@ -373,7 +373,7 @@ class Server { var client = this.clients[socket.id] if (client.user !== undefined) { - Logger.debug(`[Server] Authenticating socket client already has user`, client.user) + Logger.debug(`[Server] Authenticating socket client already has user`, client.user.username) } client.user = user diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 2b0ec24d..c85352bc 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -19,7 +19,7 @@ class CoverManager { } getCoverDirectory(libraryItem) { - if (this.db.serverSettings.storeCoverWithBook) { + if (this.db.serverSettings.storeCoverWithItem) { return libraryItem.path } else { return Path.posix.join(this.ItemMetadataPath, libraryItem.id) diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 704a49d0..6a4eb639 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,5 +1,7 @@ +const Path = require('path') const { version } = require('../../package.json') const Logger = require('../Logger') +const abmetadataGenerator = require('../utils/abmetadataGenerator') const LibraryFile = require('./files/LibraryFile') const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') @@ -36,6 +38,9 @@ class LibraryItem { if (libraryItem) { this.construct(libraryItem) } + + // Temporary attributes + this.isSavingMetadata = false } construct(libraryItem) { @@ -447,5 +452,27 @@ class LibraryItem { getDirectPlayTracklist(episodeId) { return this.media.getDirectPlayTracklist(episodeId) } + + // Saves metadata.abs file + async saveMetadata() { + if (this.isSavingMetadata) return + this.isSavingMetadata = true + + var metadataPath = Path.join(global.MetadataPath, 'items', this.id) + if (global.ServerSettings.storeMetadataWithItem) { + metadataPath = this.path + } else { + // Make sure metadata book dir exists + await fs.ensureDir(metadataPath) + } + metadataPath = Path.join(metadataPath, 'metadata.abs') + + return abmetadataGenerator.generate(this, metadataPath).then((success) => { + this.isSavingMetadata = false + if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataPath}"`) + else Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataPath}"`) + return success + }) + } } module.exports = LibraryItem \ No newline at end of file diff --git a/server/objects/ServerSettings.js b/server/objects/ServerSettings.js index 1c7e0089..43e777e7 100644 --- a/server/objects/ServerSettings.js +++ b/server/objects/ServerSettings.js @@ -17,9 +17,9 @@ class ServerSettings { this.scannerPreferOpfMetadata = false this.scannerDisableWatcher = false - // Metadata - this.storeCoverWithBook = false - this.storeMetadataWithBook = false + // Metadata - choose to store inside users library item folder + this.storeCoverWithItem = false + this.storeMetadataWithItem = false // Security/Rate limits this.rateLimitLoginRequests = 10 @@ -64,11 +64,14 @@ class ServerSettings { this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata this.scannerDisableWatcher = !!settings.scannerDisableWatcher - this.storeCoverWithBook = settings.storeCoverWithBook - if (this.storeCoverWithBook == undefined) { // storeCoverWithBook added in 1.7.1 to replace coverDestination - this.storeCoverWithBook = !!settings.coverDestination + this.storeCoverWithItem = !!settings.storeCoverWithItem + if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2 + this.storeCoverWithItem = !!settings.storeCoverWithBook + } + this.storeMetadataWithItem = !!settings.storeMetadataWithItem + if (settings.storeMetadataWithBook != undefined) { // storeMetadataWithBook was old name of setting < v2 + this.storeMetadataWithItem = !!settings.storeMetadataWithBook } - this.storeMetadataWithBook = !!settings.storeCoverWithBook this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10 this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes @@ -105,8 +108,8 @@ class ServerSettings { scannerPreferAudioMetadata: this.scannerPreferAudioMetadata, scannerPreferOpfMetadata: this.scannerPreferOpfMetadata, scannerDisableWatcher: this.scannerDisableWatcher, - storeCoverWithBook: this.storeCoverWithBook, - storeMetadataWithBook: this.storeMetadataWithBook, + storeCoverWithItem: this.storeCoverWithItem, + storeMetadataWithItem: this.storeMetadataWithItem, rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginWindow: this.rateLimitLoginWindow, backupSchedule: this.backupSchedule, diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 6cbeec38..3f02a303 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -1,9 +1,9 @@ const Path = require('path') const Logger = require('../../Logger') const BookMetadata = require('../metadata/BookMetadata') -const abmetadataGenerator = require('../../utils/abmetadataGenerator') const { areEquivalent, copyValue } = require('../../utils/index') const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata') +const abmetadataGenerator = require('../../utils/abmetadataGenerator') const { readTextFile } = require('../../utils/fileUtils') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -215,10 +215,18 @@ class Book { } } - // TODO: Implement metadata.abs var metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs') if (metadataAbs) { - + Logger.debug(`[Book] Found metadata.abs file for "${this.metadata.title}"`) + var metadataText = await readTextFile(metadataAbs.metadata.path) + var abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this.metadata, 'book') + if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) { + Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates) + metadataUpdatePayload = { + ...metadataUpdatePayload, + ...abmetadataUpdates + } + } } var metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml') diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 6669a184..15ff1958 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -2,6 +2,8 @@ const Logger = require('../../Logger') const PodcastEpisode = require('../entities/PodcastEpisode') const PodcastMetadata = require('../metadata/PodcastMetadata') const { areEquivalent, copyValue } = require('../../utils/index') +const abmetadataGenerator = require('../../utils/abmetadataGenerator') +const { readTextFile } = require('../../utils/fileUtils') const { createNewSortInstance } = require('fast-sort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -158,6 +160,21 @@ class Podcast { } async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) { + var metadataUpdatePayload = {} + + var metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs') + if (metadataAbs) { + var metadataText = await readTextFile(metadataAbs.metadata.path) + var abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this.metadata, 'podcast') + if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) { + Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates) + metadataUpdatePayload = abmetadataUpdates + } + } + + if (Object.keys(metadataUpdatePayload).length) { + return this.metadata.update(metadataUpdatePayload) + } return false } diff --git a/server/scanner/ScanOptions.js b/server/scanner/ScanOptions.js index f4543613..ccc6ca2f 100644 --- a/server/scanner/ScanOptions.js +++ b/server/scanner/ScanOptions.js @@ -5,7 +5,7 @@ class ScanOptions { // Server settings this.parseSubtitles = false this.findCovers = false - this.storeCoverWithBook = false + this.storeCoverWithItem = false this.preferAudioMetadata = false this.preferOpfMetadata = false @@ -30,7 +30,7 @@ class ScanOptions { metadataPrecedence: this.metadataPrecedence, parseSubtitles: this.parseSubtitles, findCovers: this.findCovers, - storeCoverWithBook: this.storeCoverWithBook, + storeCoverWithItem: this.storeCoverWithItem, preferAudioMetadata: this.preferAudioMetadata, preferOpfMetadata: this.preferOpfMetadata } @@ -41,7 +41,7 @@ class ScanOptions { this.parseSubtitles = !!serverSettings.scannerParseSubtitle this.findCovers = !!serverSettings.scannerFindCovers - this.storeCoverWithBook = serverSettings.storeCoverWithBook + this.storeCoverWithItem = serverSettings.storeCoverWithItem this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata } diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 2aa31270..69c4e595 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -413,6 +413,8 @@ class Scanner { return libraryItem } + // Any series or author object on library item with an id starting with "new" + // will create a new author/series OR find a matching author/series async createNewAuthorsAndSeries(libraryItem) { if (libraryItem.mediaType !== 'book') return @@ -422,11 +424,12 @@ class Scanner { libraryItem.media.metadata.authors = libraryItem.media.metadata.authors.map((tempMinAuthor) => { var _author = this.db.authors.find(au => au.checkNameEquals(tempMinAuthor.name)) if (!_author) _author = newAuthors.find(au => au.checkNameEquals(tempMinAuthor.name)) // Check new unsaved authors - if (!_author) { + if (!_author) { // Must create new author _author = new Author() _author.setData(tempMinAuthor) newAuthors.push(_author) } + return { id: _author.id, name: _author.name @@ -442,7 +445,7 @@ class Scanner { libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => { var _series = this.db.series.find(se => se.checkNameEquals(tempMinSeries.name)) if (!_series) _series = newSeries.find(se => se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series - if (!_series) { + if (!_series) { // Must create new series _series = new Series() _series.setData(tempMinSeries) newSeries.push(_series) diff --git a/server/utils/abmetadataGenerator.js b/server/utils/abmetadataGenerator.js index 974a9202..fdf3cbef 100644 --- a/server/utils/abmetadataGenerator.js +++ b/server/utils/abmetadataGenerator.js @@ -2,33 +2,155 @@ const fs = require('fs-extra') const filePerms = require('./filePerms') const package = require('../../package.json') const Logger = require('../Logger') +const { getId } = require('./index') -const bookKeyMap = { - title: 'title', - subtitle: 'subtitle', - author: 'authorFL', - narrator: 'narratorFL', - publishedYear: 'publishedYear', - publisher: 'publisher', - description: 'description', - isbn: 'isbn', - asin: 'asin', - language: 'language', - genres: 'genresCommaSeparated' + +const CurrentAbMetadataVersion = 2 +// abmetadata v1 key map +// const bookKeyMap = { +// title: 'title', +// subtitle: 'subtitle', +// author: 'authorFL', +// narrator: 'narratorFL', +// publishedYear: 'publishedYear', +// publisher: 'publisher', +// description: 'description', +// isbn: 'isbn', +// asin: 'asin', +// language: 'language', +// genres: 'genresCommaSeparated' +// } + +const commaSeparatedToArray = (v) => { + if (!v) return [] + return v.split(',').map(_v => _v.trim()).filter(_v => _v) } -function generate(audiobook, outputPath) { - var fileString = ';ABMETADATA1\n' +const podcastMetadataMapper = { + title: { + to: (m) => m.title || '', + from: (v) => v || '' + }, + author: { + to: (m) => m.author || '', + from: (v) => v || null + }, + language: { + to: (m) => m.language || '', + from: (v) => v || null + }, + genres: { + to: (m) => m.genres.join(', '), + from: (v) => commaSeparatedToArray(v) + }, + feedUrl: { + to: (m) => m.feedUrl || '', + from: (v) => v || null + }, + itunesId: { + to: (m) => m.itunesId || '', + from: (v) => v || null + }, + explicit: { + to: (m) => m.explicit ? 'Y' : 'N', + from: (v) => v && v.toLowerCase() == 'y' + } +} + +const bookMetadataMapper = { + title: { + to: (m) => m.title || '', + from: (v) => v || '' + }, + subtitle: { + to: (m) => m.subtitle || '', + from: (v) => v || null + }, + authors: { + to: (m) => m.authorName || '', + from: (v) => commaSeparatedToArray(v) + }, + narrators: { + to: (m) => m.narratorName || '', + from: (v) => commaSeparatedToArray(v) + }, + publishedYear: { + to: (m) => m.publishedYear || '', + from: (v) => v || null + }, + publisher: { + to: (m) => m.publisher || '', + from: (v) => v || null + }, + isbn: { + to: (m) => m.isbn || '', + from: (v) => v || null + }, + asin: { + to: (m) => m.asin || '', + from: (v) => v || null + }, + language: { + to: (m) => m.language || '', + from: (v) => v || null + }, + genres: { + to: (m) => m.genres.join(', '), + from: (v) => commaSeparatedToArray(v) + }, + series: { + to: (m) => m.seriesName, + from: (v) => { + return commaSeparatedToArray(v).map(series => { // Return array of { name, sequence } + var sequence = null + var name = series + var matchResults = series.match(/ #((?:\d*\.?\d+)|(?:\.?\d*))$/) // Pull out sequence # + if (matchResults.length && matchResults.length > 1) { + sequence = matchResults[1] // Group 1 + name = series.replace(matchResults[0], '') + } + return { + name, + sequence + } + }) + } + }, + explicit: { + to: (m) => m.explicit ? 'Y' : 'N', + from: (v) => v && v.toLowerCase() == 'y' + } +} + +const metadataMappers = { + book: bookMetadataMapper, + podcast: podcastMetadataMapper +} + +function generate(libraryItem, outputPath) { + var fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n` fileString += `#audiobookshelf v${package.version}\n\n` - for (const key in bookKeyMap) { - const value = audiobook.book[bookKeyMap[key]] || '' - fileString += `${key}=${value}\n` + const mediaType = libraryItem.mediaType + + fileString += `media=${mediaType}\n` + + const metadataMapper = metadataMappers[mediaType] + var mediaMetadata = libraryItem.media.metadata + for (const key in metadataMapper) { + fileString += `${key}=${metadataMapper[key].to(mediaMetadata)}\n` } - if (audiobook.chapters.length) { + // Description block + if (mediaMetadata.description) { + fileString += '\n[DESCRIPTION]\n' + fileString += mediaMetadata.description + '\n' + } + + // Book chapters + if (libraryItem.mediaType == 'book' && libraryItem.media.chapters.length) { fileString += '\n' - audiobook.chapters.forEach((chapter) => { + libraryItem.media.chapters.forEach((chapter) => { fileString += `[CHAPTER]\n` fileString += `start=${chapter.start}\n` fileString += `end=${chapter.end}\n` @@ -45,7 +167,61 @@ function generate(audiobook, outputPath) { } module.exports.generate = generate -function parseAbMetadataText(text) { +function parseSections(lines) { + if (!lines || !lines.length || !lines[0].startsWith('[')) { // First line must be section start + return [] + } + + var sections = [] + var currentSection = [] + lines.forEach(line => { + if (!line || !line.trim()) return + + if (line.startsWith('[') && currentSection.length) { // current section ended + sections.push(currentSection) + currentSection = [] + } + + currentSection.push(line) + }) + if (currentSection.length) sections.push(currentSection) + return sections +} + +// lines inside chapter section +function parseChapterLines(lines) { + var chapter = { + start: null, + end: null, + title: null + } + + lines.forEach((line) => { + var keyValue = line.split('=') + if (keyValue.length > 1) { + var key = keyValue[0].trim() + var value = keyValue[1].trim() + + if (key === 'start' || key === 'end') { + if (!isNaN(value)) { + chapter[key] = Number(value) + } else { + Logger.warn(`[abmetadataGenerator] Invalid chapter value for ${key}: ${value}`) + } + } else if (key === 'title') { + chapter[key] = value + } + } + }) + + if (chapter.start === null || chapter.end === null || chapter.end > chapter.start) { + Logger.warn(`[abmetadataGenerator] Invalid chapter`) + return null + } + return chapter +} + +function parseAbMetadataText(text, mediaType) { if (!text) return null var lines = text.split(/\r?\n/) @@ -56,45 +232,184 @@ function parseAbMetadataText(text) { return null } var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim()) - if (isNaN(abmetadataVersion)) { - Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - using 1`) - abmetadataVersion = 1 + if (isNaN(abmetadataVersion) || abmetadataVersion != CurrentAbMetadataVersion) { + Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - must use version ${CurrentAbMetadataVersion}`) + return null } // Remove comments and empty lines const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0])) - // Get lines that map to book details (all lines before the first chapter section) + // Get lines that map to book details (all lines before the first chapter or description section) var firstSectionLine = lines.findIndex(l => l.startsWith('[')) var detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines + var remainingLines = firstSectionLine > 0 ? lines.slice(firstSectionLine) : [] + if (!detailLines.length) { + Logger.error(`Invalid abmetadata file no detail lines`) + return null + } + + // Check the media type saved for this abmetadata file show warning if not matching expected + if (detailLines[0].toLowerCase().startsWith('media=')) { + var mediaLine = detailLines.shift() // Remove media line + var abMediaType = mediaLine.toLowerCase().split('=')[1].trim() + if (abMediaType != mediaType) { + Logger.warn(`Invalid media type in abmetadata file ${abMediaType} expecting ${mediaType}`) + } + } else { + Logger.warn(`No media type found in abmetadata file - expecting ${mediaType}`) + } + + const metadataMapper = metadataMappers[mediaType] // Put valid book detail values into map - const bookDetails = {} + const mediaMetadataDetails = {} for (let i = 0; i < detailLines.length; i++) { var line = detailLines[i] var keyValue = line.split('=') if (keyValue.length < 2) { Logger.warn('abmetadata invalid line has no =', line) - } else if (!bookKeyMap[keyValue[0].trim()]) { - Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid book detail key`) + } else if (!metadataMapper[keyValue[0].trim()]) { + Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`) } else { var key = keyValue[0].trim() - bookDetails[key] = keyValue[1].trim() - - // Genres convert to array of strings - if (key === 'genres') { - bookDetails[key] = bookDetails[key] ? bookDetails[key].split(',').map(genre => genre.trim()) : [] - } else if (!bookDetails[key]) { // Use null for empty details - bookDetails[key] = null - } + var value = keyValue[1].trim() + mediaMetadataDetails[key] = metadataMapper[key].from(value) } } - // TODO: Chapter support + const chapters = [] + + // Parse sections for description and chapters + var sections = parseSections(remainingLines) + sections.forEach((section) => { + var sectionHeader = section.shift() + if (sectionHeader.toLowerCase().startsWith('[description]')) { + mediaMetadataDetails.description = section.join('\n') + } else if (sectionHeader.toLowerCase().startsWith('[chapter]')) { + var chapter = parseChapterLines(section) + if (chapter) { + chapters.push(chapter) + } + } + }) + + chapters.sort((a, b) => a.start - b.start) return { - book: bookDetails + metadata: mediaMetadataDetails, + chapters } } -module.exports.parse = parseAbMetadataText \ No newline at end of file +module.exports.parse = parseAbMetadataText + +function checkUpdatedBookAuthors(abmetadataAuthors, authors) { + var finalAuthors = [] + var hasUpdates = false + + abmetadataAuthors.forEach((authorName) => { + var findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase()) + if (!findAuthor) { + hasUpdates = true + finalAuthors.push({ + id: getId('new'), // New author gets created in Scanner.js after library scan + name: authorName + }) + } else { + finalAuthors.push(findAuthor) + } + }) + + var authorsRemoved = authors.filter(au => !abmetadataAuthors.some(auname => auname.toLowerCase() == au.name.toLowerCase())) + if (authorsRemoved.length) { + hasUpdates = true + } + + return { + authors: finalAuthors, + hasUpdates + } +} + +function checkUpdatedBookSeries(abmetadataSeries, series) { + var finalSeries = [] + var hasUpdates = false + + abmetadataSeries.forEach((seriesObj) => { + var findSeries = series.find(se => se.name.toLowerCase() == seriesObj.name.toLowerCase()) + if (!findSeries) { + hasUpdates = true + newUpdatedSeries.push({ + id: getId('new'), // New series gets created in Scanner.js after library scan + name: seriesObj.name, + sequence: seriesObj.sequence + }) + } else if (findSeries.sequence != seriesObj.sequence) { // Sequence was updated + hasUpdates = true + newUpdatedSeries.push({ + id: findSeries.id, + name: findSeries.name, + sequence: seriesObj.sequence + }) + } else { + finalSeries.push(findSeries) + } + }) + + var seriesRemoved = series.filter(se => !abmetadataSeries.some(_se => _se.name.toLowerCase() == se.name.toLowerCase())) + if (seriesRemoved.length) { + hasUpdates = true + } + + return { + series: finalSeries, + hasUpdates + } +} + +function checkArraysChanged(abmetadataArray, mediaArray) { + if (!Array.isArray(abmetadataArray)) return false + if (!Array.isArray(mediaArray)) return true + return abmetadataArray.join(',') != mediaArray.join(',') +} + +// Input text from abmetadata file and return object of metadata changes from media metadata +function parseAndCheckForUpdates(text, mediaMetadata, mediaType) { + if (!text || !mediaMetadata || !mediaType) { + Logger.error(`Invalid inputs to parseAndCheckForUpdates`) + return null + } + + var updatePayload = {} // Only updated key/values + + var abmetadataData = parseAbMetadataText(text, mediaType) + if (!abmetadataData || !abmetadataData.metadata) { + return null + } + + var abMetadata = abmetadataData.metadata // Metadata from abmetadata file + + for (const key in abMetadata) { + if (mediaMetadata[key] !== undefined) { + if (key === 'authors') { + var authorUpdatePayload = checkUpdatedBookAuthors(abMetadata[key], mediaMetadata[key]) + if (authorUpdatePayload.hasUpdates) updatePayload.authors = authorUpdatePayload.authors + } else if (key === 'series') { + var seriesUpdatePayload = checkUpdatedBookSeries(abMetadata[key], mediaMetadata[key]) + if (seriesUpdatePayload.hasUpdates) updatePayload.series = seriesUpdatePayload.series + } else if (key === 'genres' || key === 'narrators') { // Compare array differences + if (checkArraysChanged(abMetadata[key], mediaMetadata[key])) { + updatePayload[key] = abMetadata[key] + } + } else if (abMetadata[key] !== mediaMetadata[key]) { + updatePayload[key] = abMetadata[key] + } + } else { + Logger.warn('[abmetadataGenerator] Invalid key', key) + } + } + + return updatePayload +} +module.exports.parseAndCheckForUpdates = parseAndCheckForUpdates \ No newline at end of file