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,15 +5,15 @@ const { LogLevel } = require('../utils/constants')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
class AbsMetadataFileScanner { class AbsMetadataFileScanner {
constructor() { } constructor() {}
/** /**
* Check for metadata.json file and set book metadata * Check for metadata.json file and set book metadata
* *
* @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryScan')} libraryScan
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {Object} bookMetadata * @param {Object} bookMetadata
* @param {string} [existingLibraryItemId] * @param {string} [existingLibraryItemId]
*/ */
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) { async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
if (metadataText) { if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
for (const key in abMetadata) { for (const key in abMetadata) {
// TODO: When to override with null or empty arrays? // TODO: When to override with null or empty arrays?
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
@ -48,11 +49,11 @@ class AbsMetadataFileScanner {
/** /**
* Check for metadata.json file and set podcast metadata * Check for metadata.json file and set podcast metadata
* *
* @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryScan')} libraryScan
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {Object} podcastMetadata * @param {Object} podcastMetadata
* @param {string} [existingLibraryItemId] * @param {string} [existingLibraryItemId]
*/ */
async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) { async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
if (metadataText) { if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
for (const key in abMetadata) { for (const key in abMetadata) {
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
if (key === 'tags' && !abMetadata.tags?.length) continue if (key === 'tags' && !abMetadata.tags?.length) continue
@ -81,4 +82,4 @@ class AbsMetadataFileScanner {
} }
} }
} }
module.exports = new AbsMetadataFileScanner() module.exports = new AbsMetadataFileScanner()

View file

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

View file

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

View file

@ -1,7 +1,51 @@
const Logger = require('../../Logger') const Logger = require('../../Logger')
const parseSeriesString = require('../parsers/parseSeriesString') 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 { try {
const abmetadataData = JSON.parse(text) const abmetadataData = JSON.parse(text)
@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
} }
delete abmetadataData.metadata delete abmetadataData.metadata
if (abmetadataData.series?.length) { const expectedKeys = mediaTypeKeys[mediaType]
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))] if (!expectedKeys) {
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series)) Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
return null
} }
// clean tags & remove dupes
if (abmetadataData.tags?.length) { const validated = {}
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))] 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) { if (mediaType === 'book' && 'chapters' in abmetadataData) {
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))] 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))] return validated
}
if (abmetadataData.genres?.length) {
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
}
return abmetadataData
} catch (error) { } catch (error) {
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error) Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
return null return null
@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
} }
module.exports.parseJson = parseJsonMetadataText 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) { function cleanChaptersArray(chaptersArray, mediaTitle) {
const chapters = [] const chapters = []
let index = 0 let index = 0