Update abmetadata file for new data model, add chapter and description section parser

This commit is contained in:
advplyr 2022-04-12 16:05:16 -05:00
parent 642e9787c0
commit 10d9e11387
12 changed files with 439 additions and 73 deletions

View File

@ -5,7 +5,7 @@
<h1 class="text-xl">Backups</h1> <h1 class="text-xl">Backups</h1>
</div> </div>
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p> <p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/items</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" /> <ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />

View File

@ -8,20 +8,20 @@
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithBook" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithBook', val)" /> <ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="tooltips.storeCoverWithBook"> <ui-tooltip :text="tooltips.storeCoverWithItem">
<p class="pl-4 text-lg"> <p class="pl-4 text-lg">
Store covers with book Store covers with item
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithBook" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithBook', val)" /> <ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="tooltips.storeMetadataWithBook"> <ui-tooltip :text="tooltips.storeMetadataWithItem">
<p class="pl-4 text-lg"> <p class="pl-4 text-lg">
Store metadata with book Store metadata with item
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@ -47,10 +47,6 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<p class="pl-4 text-lg">Ignore prefixes when sorting title and series</p>
</div> -->
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" /> <ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="tooltips.sortingIgnorePrefix"> <ui-tooltip :text="tooltips.sortingIgnorePrefix">
@ -218,8 +214,8 @@ export default {
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"', 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.<br>Note: This will extend scan time', 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.<br>Note: This will extend scan time',
bookshelfView: 'Alternative bookshelf view that shows title & author under book covers', 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', 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',
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', 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' coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
}, },
showConfirmPurgeCache: false showConfirmPurgeCache: false

View File

@ -1,11 +1,8 @@
const Path = require('path') const Path = require('path')
// const njodb = require("njodb")
const njodb = require('./njodb') const njodb = require('./njodb')
const fs = require('fs-extra')
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const Logger = require('./Logger') const Logger = require('./Logger')
const { version } = require('../package.json') const { version } = require('../package.json')
// const Audiobook = require('./objects/Audiobook')
const LibraryItem = require('./objects/LibraryItem') const LibraryItem = require('./objects/LibraryItem')
const User = require('./objects/user/User') const User = require('./objects/user/User')
const UserCollection = require('./objects/UserCollection') const UserCollection = require('./objects/UserCollection')

View File

@ -373,7 +373,7 @@ class Server {
var client = this.clients[socket.id] var client = this.clients[socket.id]
if (client.user !== undefined) { 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 client.user = user

View File

@ -19,7 +19,7 @@ class CoverManager {
} }
getCoverDirectory(libraryItem) { getCoverDirectory(libraryItem) {
if (this.db.serverSettings.storeCoverWithBook) { if (this.db.serverSettings.storeCoverWithItem) {
return libraryItem.path return libraryItem.path
} else { } else {
return Path.posix.join(this.ItemMetadataPath, libraryItem.id) return Path.posix.join(this.ItemMetadataPath, libraryItem.id)

View File

@ -1,5 +1,7 @@
const Path = require('path')
const { version } = require('../../package.json') const { version } = require('../../package.json')
const Logger = require('../Logger') const Logger = require('../Logger')
const abmetadataGenerator = require('../utils/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile') const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book') const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast') const Podcast = require('./mediaTypes/Podcast')
@ -36,6 +38,9 @@ class LibraryItem {
if (libraryItem) { if (libraryItem) {
this.construct(libraryItem) this.construct(libraryItem)
} }
// Temporary attributes
this.isSavingMetadata = false
} }
construct(libraryItem) { construct(libraryItem) {
@ -447,5 +452,27 @@ class LibraryItem {
getDirectPlayTracklist(episodeId) { getDirectPlayTracklist(episodeId) {
return this.media.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 module.exports = LibraryItem

View File

@ -17,9 +17,9 @@ class ServerSettings {
this.scannerPreferOpfMetadata = false this.scannerPreferOpfMetadata = false
this.scannerDisableWatcher = false this.scannerDisableWatcher = false
// Metadata // Metadata - choose to store inside users library item folder
this.storeCoverWithBook = false this.storeCoverWithItem = false
this.storeMetadataWithBook = false this.storeMetadataWithItem = false
// Security/Rate limits // Security/Rate limits
this.rateLimitLoginRequests = 10 this.rateLimitLoginRequests = 10
@ -64,11 +64,14 @@ class ServerSettings {
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
this.scannerDisableWatcher = !!settings.scannerDisableWatcher this.scannerDisableWatcher = !!settings.scannerDisableWatcher
this.storeCoverWithBook = settings.storeCoverWithBook this.storeCoverWithItem = !!settings.storeCoverWithItem
if (this.storeCoverWithBook == undefined) { // storeCoverWithBook added in 1.7.1 to replace coverDestination if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2
this.storeCoverWithBook = !!settings.coverDestination 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.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
@ -105,8 +108,8 @@ class ServerSettings {
scannerPreferAudioMetadata: this.scannerPreferAudioMetadata, scannerPreferAudioMetadata: this.scannerPreferAudioMetadata,
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata, scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
scannerDisableWatcher: this.scannerDisableWatcher, scannerDisableWatcher: this.scannerDisableWatcher,
storeCoverWithBook: this.storeCoverWithBook, storeCoverWithItem: this.storeCoverWithItem,
storeMetadataWithBook: this.storeMetadataWithBook, storeMetadataWithItem: this.storeMetadataWithItem,
rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow, rateLimitLoginWindow: this.rateLimitLoginWindow,
backupSchedule: this.backupSchedule, backupSchedule: this.backupSchedule,

View File

@ -1,9 +1,9 @@
const Path = require('path') const Path = require('path')
const Logger = require('../../Logger') const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata') const BookMetadata = require('../metadata/BookMetadata')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const { areEquivalent, copyValue } = require('../../utils/index') const { areEquivalent, copyValue } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata') const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const { readTextFile } = require('../../utils/fileUtils') const { readTextFile } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile') const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack') 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') var metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
if (metadataAbs) { 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') var metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml')

View File

@ -2,6 +2,8 @@ const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode') const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata') const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue } = require('../../utils/index') const { areEquivalent, copyValue } = require('../../utils/index')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const { readTextFile } = require('../../utils/fileUtils')
const { createNewSortInstance } = require('fast-sort') const { createNewSortInstance } = require('fast-sort')
const naturalSort = createNewSortInstance({ const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@ -158,6 +160,21 @@ class Podcast {
} }
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) { 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 return false
} }

View File

@ -5,7 +5,7 @@ class ScanOptions {
// Server settings // Server settings
this.parseSubtitles = false this.parseSubtitles = false
this.findCovers = false this.findCovers = false
this.storeCoverWithBook = false this.storeCoverWithItem = false
this.preferAudioMetadata = false this.preferAudioMetadata = false
this.preferOpfMetadata = false this.preferOpfMetadata = false
@ -30,7 +30,7 @@ class ScanOptions {
metadataPrecedence: this.metadataPrecedence, metadataPrecedence: this.metadataPrecedence,
parseSubtitles: this.parseSubtitles, parseSubtitles: this.parseSubtitles,
findCovers: this.findCovers, findCovers: this.findCovers,
storeCoverWithBook: this.storeCoverWithBook, storeCoverWithItem: this.storeCoverWithItem,
preferAudioMetadata: this.preferAudioMetadata, preferAudioMetadata: this.preferAudioMetadata,
preferOpfMetadata: this.preferOpfMetadata preferOpfMetadata: this.preferOpfMetadata
} }
@ -41,7 +41,7 @@ class ScanOptions {
this.parseSubtitles = !!serverSettings.scannerParseSubtitle this.parseSubtitles = !!serverSettings.scannerParseSubtitle
this.findCovers = !!serverSettings.scannerFindCovers this.findCovers = !!serverSettings.scannerFindCovers
this.storeCoverWithBook = serverSettings.storeCoverWithBook this.storeCoverWithItem = serverSettings.storeCoverWithItem
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
} }

View File

@ -413,6 +413,8 @@ class Scanner {
return libraryItem 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) { async createNewAuthorsAndSeries(libraryItem) {
if (libraryItem.mediaType !== 'book') return if (libraryItem.mediaType !== 'book') return
@ -422,11 +424,12 @@ class Scanner {
libraryItem.media.metadata.authors = libraryItem.media.metadata.authors.map((tempMinAuthor) => { libraryItem.media.metadata.authors = libraryItem.media.metadata.authors.map((tempMinAuthor) => {
var _author = this.db.authors.find(au => au.checkNameEquals(tempMinAuthor.name)) 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) _author = newAuthors.find(au => au.checkNameEquals(tempMinAuthor.name)) // Check new unsaved authors
if (!_author) { if (!_author) { // Must create new author
_author = new Author() _author = new Author()
_author.setData(tempMinAuthor) _author.setData(tempMinAuthor)
newAuthors.push(_author) newAuthors.push(_author)
} }
return { return {
id: _author.id, id: _author.id,
name: _author.name name: _author.name
@ -442,7 +445,7 @@ class Scanner {
libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => { libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => {
var _series = this.db.series.find(se => se.checkNameEquals(tempMinSeries.name)) 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) _series = newSeries.find(se => se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series
if (!_series) { if (!_series) { // Must create new series
_series = new Series() _series = new Series()
_series.setData(tempMinSeries) _series.setData(tempMinSeries)
newSeries.push(_series) newSeries.push(_series)

View File

@ -2,33 +2,155 @@ const fs = require('fs-extra')
const filePerms = require('./filePerms') const filePerms = require('./filePerms')
const package = require('../../package.json') const package = require('../../package.json')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getId } = require('./index')
const bookKeyMap = {
title: 'title', const CurrentAbMetadataVersion = 2
subtitle: 'subtitle', // abmetadata v1 key map
author: 'authorFL', // const bookKeyMap = {
narrator: 'narratorFL', // title: 'title',
publishedYear: 'publishedYear', // subtitle: 'subtitle',
publisher: 'publisher', // author: 'authorFL',
description: 'description', // narrator: 'narratorFL',
isbn: 'isbn', // publishedYear: 'publishedYear',
asin: 'asin', // publisher: 'publisher',
language: 'language', // description: 'description',
genres: 'genresCommaSeparated' // 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) { const podcastMetadataMapper = {
var fileString = ';ABMETADATA1\n' 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` fileString += `#audiobookshelf v${package.version}\n\n`
for (const key in bookKeyMap) { const mediaType = libraryItem.mediaType
const value = audiobook.book[bookKeyMap[key]] || ''
fileString += `${key}=${value}\n` 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' fileString += '\n'
audiobook.chapters.forEach((chapter) => { libraryItem.media.chapters.forEach((chapter) => {
fileString += `[CHAPTER]\n` fileString += `[CHAPTER]\n`
fileString += `start=${chapter.start}\n` fileString += `start=${chapter.start}\n`
fileString += `end=${chapter.end}\n` fileString += `end=${chapter.end}\n`
@ -45,7 +167,61 @@ function generate(audiobook, outputPath) {
} }
module.exports.generate = generate 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 if (!text) return null
var lines = text.split(/\r?\n/) var lines = text.split(/\r?\n/)
@ -56,45 +232,184 @@ function parseAbMetadataText(text) {
return null return null
} }
var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim()) var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim())
if (isNaN(abmetadataVersion)) { if (isNaN(abmetadataVersion) || abmetadataVersion != CurrentAbMetadataVersion) {
Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - using 1`) Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - must use version ${CurrentAbMetadataVersion}`)
abmetadataVersion = 1 return null
} }
// Remove comments and empty lines // Remove comments and empty lines
const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following
lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0])) 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 firstSectionLine = lines.findIndex(l => l.startsWith('['))
var detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines 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 // Put valid book detail values into map
const bookDetails = {} const mediaMetadataDetails = {}
for (let i = 0; i < detailLines.length; i++) { for (let i = 0; i < detailLines.length; i++) {
var line = detailLines[i] var line = detailLines[i]
var keyValue = line.split('=') var keyValue = line.split('=')
if (keyValue.length < 2) { if (keyValue.length < 2) {
Logger.warn('abmetadata invalid line has no =', line) Logger.warn('abmetadata invalid line has no =', line)
} else if (!bookKeyMap[keyValue[0].trim()]) { } else if (!metadataMapper[keyValue[0].trim()]) {
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid book detail key`) Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`)
} else { } else {
var key = keyValue[0].trim() var key = keyValue[0].trim()
bookDetails[key] = keyValue[1].trim() var value = keyValue[1].trim()
mediaMetadataDetails[key] = metadataMapper[key].from(value)
// 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
}
} }
} }
// 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 { return {
book: bookDetails metadata: mediaMetadataDetails,
chapters
} }
} }
module.exports.parse = parseAbMetadataText 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