Cleanup abmetadata file parsing & fix server crash #5268 #4287 #5142
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run

This commit is contained in:
advplyr 2026-05-27 17:33:14 -05:00
parent c009db9f28
commit 1bad2d9072
4 changed files with 145 additions and 33 deletions

View file

@ -5,7 +5,7 @@ const { LogLevel } = require('../utils/constants')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
class AbsMetadataFileScanner {
constructor() { }
constructor() {}
/**
* Check for metadata.json file and set book metadata
@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
for (const key in abMetadata) {
// TODO: When to override with null or empty arrays?
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
for (const key in abMetadata) {
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
if (key === 'tags' && !abMetadata.tags?.length) continue

View file

@ -825,6 +825,9 @@ class BookScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
/**
* Keys must match abmetadataGenerator.js
*/
const jsonObject = {
tags: libraryItem.media.tags || [],
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],

View file

@ -425,6 +425,9 @@ class PodcastScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
/**
* Keys must match abmetadataGenerator.js
*/
const jsonObject = {
tags: libraryItem.media.tags || [],
title: libraryItem.media.title,

View file

@ -1,7 +1,51 @@
const Logger = require('../../Logger')
const parseSeriesString = require('../parsers/parseSeriesString')
function parseJsonMetadataText(text) {
const mediaTypeKeys = {
book: {
tags: 'stringArray',
title: 'string',
subtitle: 'string',
authors: 'stringArray',
narrators: 'stringArray',
series: 'stringArray',
genres: 'stringArray',
publishedYear: 'string',
publishedDate: 'string',
publisher: 'string',
description: 'string',
isbn: 'string',
asin: 'string',
language: 'string',
explicit: 'boolean',
abridged: 'boolean'
},
podcast: {
tags: 'stringArray',
title: 'string',
author: 'string',
description: 'string',
releaseDate: 'string',
genres: 'stringArray',
feedURL: 'string',
imageURL: 'string',
itunesPageURL: 'string',
itunesId: 'string',
itunesArtistId: 'string',
asin: 'string',
language: 'string',
explicit: 'boolean',
podcastType: 'string'
}
}
/**
*
* @param {string} text
* @param {"book" | "podcast"} mediaType
* @returns {Object}
*/
function parseJsonMetadataText(text, mediaType) {
try {
const abmetadataData = JSON.parse(text)
@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
}
delete abmetadataData.metadata
if (abmetadataData.series?.length) {
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))]
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series))
const expectedKeys = mediaTypeKeys[mediaType]
if (!expectedKeys) {
Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
return null
}
// clean tags & remove dupes
if (abmetadataData.tags?.length) {
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))]
const validated = {}
for (const key in expectedKeys) {
const expectedType = expectedKeys[key]
if (!(key in abmetadataData)) continue
const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType)
if (validatedValue !== undefined) {
validated[key] = validatedValue
}
}
if (abmetadataData.chapters?.length) {
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)
if (validated.series?.length) {
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
}
// clean remove dupes
if (abmetadataData.authors?.length) {
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))]
if (mediaType === 'book' && 'chapters' in abmetadataData) {
if (abmetadataData.chapters === null) {
validated.chapters = []
} else if (Array.isArray(abmetadataData.chapters)) {
const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title)
if (cleanedChapters) {
validated.chapters = cleanedChapters
}
} else {
Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`)
}
}
if (abmetadataData.narrators?.length) {
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))]
}
if (abmetadataData.genres?.length) {
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
}
return abmetadataData
return validated
} catch (error) {
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
return null
@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
}
module.exports.parseJson = parseJsonMetadataText
/**
* @param {string} key
* @param {*} value
* @param {string} expectedType
* @returns {*|undefined} undefined excludes the key
*/
function validateMetadataValue(key, value, expectedType) {
if (expectedType === 'string') {
if (value === null) return null
if (typeof value === 'number') return String(value)
if (typeof value === 'string') return value
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`)
return undefined
}
if (expectedType === 'boolean') {
if (value === null) return null
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const lower = value.toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
}
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`)
return undefined
}
// Filter empty strings and deduplicate
if (expectedType === 'stringArray') {
if (value === null) return []
if (!Array.isArray(value)) {
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`)
return undefined
}
const cleanedArray = value.filter((t) => typeof t === 'string')
return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
}
Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
return undefined
}
/**
* @param {Object[]} chaptersArray
* @param {string} mediaTitle
* @returns {Object[]}
*/
function cleanChaptersArray(chaptersArray, mediaTitle) {
const chapters = []
let index = 0