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 @@
-
updateSettingsKey('storeCoverWithBook', val)" />
-
+ updateSettingsKey('storeCoverWithItem', val)" />
+
- Store covers with book
+ Store covers with item
info_outlined
-
updateSettingsKey('storeMetadataWithBook', val)" />
-
+ updateSettingsKey('storeMetadataWithItem', val)" />
+
- Store metadata with book
+ Store metadata with item
info_outlined
@@ -47,10 +47,6 @@
-
updateSettingsKey('sortingIgnorePrefix', val)" />
@@ -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