diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 7f0170c7..93a4f803 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -39,11 +39,15 @@ <ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" /> </div> - <div class="flex items-center py-2"> + <div class="flex items-center py-2 mb-2"> <ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" /> <p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p> </div> + <div class="w-44 mb-2"> + <ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="(val) => updateSettingsKey('metadataFileFormat', val)" :disabled="updatingServerSettings" /> + </div> + <div class="pt-4"> <h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2> </div> @@ -272,7 +276,17 @@ export default { useBookshelfView: false, isPurgingCache: false, newServerSettings: {}, - showConfirmPurgeCache: false + showConfirmPurgeCache: false, + metadataFileFormats: [ + { + text: '.json', + value: 'json' + }, + { + text: '.abs', + value: 'abs' + } + ] } }, watch: { diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index b15e53c9..0d001799 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -499,14 +499,37 @@ class LibraryItem { // 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 - }) + const metadataFileFormat = global.ServerSettings.metadataFileFormat + const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) + + if (metadataFileFormat === 'json') { + // Remove metadata.abs if it exists + if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) { + Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`) + await fs.remove(Path.join(metadataPath, `metadata.abs`)) + } + + return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(() => { + return true + }).catch((error) => { + Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) + return false + }) + } else { + // Remove metadata.json if it exists + if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) { + Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`) + await fs.remove(Path.join(metadataPath, `metadata.json`)) + } + + return abmetadataGenerator.generate(this, metadataFilePath).then((success) => { + this.isSavingMetadata = false + if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`) + else Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) + return success + }) + } } removeLibraryFile(ino) { diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 18c6ea52..d0cfab93 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -89,6 +89,14 @@ class Book { } } + toJSONForMetadataFile() { + return { + tags: [...this.tags], + chapters: this.chapters.map(c => ({ ...c })), + metadata: this.metadata.toJSONForMetadataFile() + } + } + get size() { var total = 0 this.audioFiles.forEach((af) => total += af.metadata.size) @@ -248,11 +256,12 @@ class Book { } } - const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs') + const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs' || lf.metadata.filename === 'metadata.json') if (metadataAbs) { - Logger.debug(`[Book] Found metadata.abs file for "${this.metadata.title}"`) + const isJSON = metadataAbs.metadata.filename === 'metadata.json' + Logger.debug(`[Book] Found ${metadataAbs.metadata.filename} file for "${this.metadata.title}"`) const metadataText = await readTextFile(metadataAbs.metadata.path) - const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book') + const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book', isJSON) if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) { Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates) diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 46c34a39..e1227deb 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -94,6 +94,13 @@ class Podcast { } } + toJSONForMetadataFile() { + return { + tags: [...this.tags], + metadata: this.metadata.toJSON() + } + } + get size() { var total = 0 this.episodes.forEach((ep) => total += ep.size) @@ -199,10 +206,11 @@ class Podcast { let metadataUpdatePayload = {} let tagsUpdated = false - const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs') + const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs' || lf.metadata.filename === 'metadata.json') if (metadataAbs) { + const isJSON = metadataAbs.metadata.filename === 'metadata.json' const metadataText = await readTextFile(metadataAbs.metadata.path) - const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast') + const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast', isJSON) if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) { Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates) diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 586d699a..7cdbdcff 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -109,6 +109,16 @@ class BookMetadata { } } + toJSONForMetadataFile() { + const json = this.toJSON() + json.authors = json.authors.map(au => au.name) + json.series = json.series.map(se => { + if (!se.sequence) return se.name + return `${se.name} #${se.sequence}` + }) + return json + } + clone() { return new BookMetadata(this.toJSON()) } @@ -191,8 +201,9 @@ class BookMetadata { } update(payload) { - var json = this.toJSON() - var hasUpdates = false + const json = this.toJSON() + let hasUpdates = false + for (const key in json) { if (payload[key] !== undefined) { if (!areEquivalent(payload[key], json[key])) { @@ -373,8 +384,10 @@ class BookMetadata { const parsed = parseNameString.parse(authorsTag) if (!parsed) return [] return (parsed.names || []).map((au) => { + const findAuthor = this.authors.find(_au => _au.name == au) + return { - id: `new-${Math.floor(Math.random() * 1000000)}`, + id: findAuthor?.id || `new-${Math.floor(Math.random() * 1000000)}`, name: au } }) diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 1dbd3417..16855c38 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -1,5 +1,4 @@ const { BookshelfView } = require('../../utils/constants') -const { isNullOrNaN } = require('../../utils') const Logger = require('../../Logger') class ServerSettings { @@ -21,6 +20,7 @@ class ServerSettings { // Metadata - choose to store inside users library item folder this.storeCoverWithItem = false this.storeMetadataWithItem = false + this.metadataFileFormat = 'json' // Security/Rate limits this.rateLimitLoginRequests = 10 @@ -77,6 +77,7 @@ class ServerSettings { this.storeCoverWithItem = !!settings.storeCoverWithItem this.storeMetadataWithItem = !!settings.storeMetadataWithItem + this.metadataFileFormat = settings.metadataFileFormat || 'json' this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10 this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes @@ -112,6 +113,16 @@ class ServerSettings { if (settings.homeBookshelfView == undefined) { // homeBookshelfView was added in 2.1.3 this.homeBookshelfView = settings.bookshelfView } + if (settings.metadataFileFormat == undefined) { // metadataFileFormat was added in 2.2.21 + // All users using old settings will stay abs until changed + this.metadataFileFormat = 'abs' + } + + // Validation + if (!['abs', 'json'].includes(this.metadataFileFormat)) { + Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`) + this.metadataFileFormat = 'json' + } if (this.logLevel !== Logger.logLevel) { Logger.setLogLevel(this.logLevel) @@ -133,6 +144,7 @@ class ServerSettings { scannerUseTone: this.scannerUseTone, storeCoverWithItem: this.storeCoverWithItem, storeMetadataWithItem: this.storeMetadataWithItem, + metadataFileFormat: this.metadataFileFormat, rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginWindow: this.rateLimitLoginWindow, backupSchedule: this.backupSchedule, diff --git a/server/utils/abmetadataGenerator.js b/server/utils/abmetadataGenerator.js index e6fb0b70..26cffbae 100644 --- a/server/utils/abmetadataGenerator.js +++ b/server/utils/abmetadataGenerator.js @@ -2,7 +2,7 @@ const fs = require('../libs/fsExtra') const filePerms = require('./filePerms') const package = require('../../package.json') const Logger = require('../Logger') -const { getId, copyValue } = require('./index') +const { getId } = require('./index') const CurrentAbMetadataVersion = 2 @@ -328,11 +328,11 @@ function parseAbMetadataText(text, mediaType) { module.exports.parse = parseAbMetadataText function checkUpdatedBookAuthors(abmetadataAuthors, authors) { - var finalAuthors = [] - var hasUpdates = false + const finalAuthors = [] + let hasUpdates = false abmetadataAuthors.forEach((authorName) => { - var findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase()) + const findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase()) if (!findAuthor) { hasUpdates = true finalAuthors.push({ @@ -397,18 +397,54 @@ function checkArraysChanged(abmetadataArray, mediaArray) { return abmetadataArray.join(',') != mediaArray.join(',') } +function parseJsonMetadataText(text) { + try { + const abmetadataData = JSON.parse(text) + if (abmetadataData.metadata?.series?.length) { + abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => { + let sequence = null + let name = series + // Series sequence match any characters after " #" other than whitespace and another # + // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. + const matchResults = series.match(/ #([^#\s]+)$/) // Pull out sequence # + if (matchResults && matchResults.length && matchResults.length > 1) { + sequence = matchResults[1] // Group 1 + name = series.replace(matchResults[0], '') + } + return { + name, + sequence + } + }) + } + return abmetadataData + } catch (error) { + Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error) + return null + } +} + // Input text from abmetadata file and return object of media changes // only returns object of changes. empty object means no changes -function parseAndCheckForUpdates(text, media, mediaType) { +function parseAndCheckForUpdates(text, media, mediaType, isJSON) { if (!text || !media || !media.metadata || !mediaType) { Logger.error(`Invalid inputs to parseAndCheckForUpdates`) return null } + const mediaMetadata = media.metadata const metadataUpdatePayload = {} // Only updated key/values - const abmetadataData = parseAbMetadataText(text, mediaType) + let abmetadataData = null + + if (isJSON) { + abmetadataData = parseJsonMetadataText(text) + } else { + abmetadataData = parseAbMetadataText(text, mediaType) + } + if (!abmetadataData || !abmetadataData.metadata) { + Logger.error(`[abmetadataGenerator] Invalid metadata file`) return null } diff --git a/server/utils/globals.js b/server/utils/globals.js index 71c1c2c0..7d20dfe3 100644 --- a/server/utils/globals.js +++ b/server/utils/globals.js @@ -4,7 +4,7 @@ const globals = { SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], SupportedVideoTypes: ['mp4'], TextFileTypes: ['txt', 'nfo'], - MetadataFileTypes: ['opf', 'abs', 'xml'] + MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] } module.exports = globals