audiobookshelf/server/providers/OpenAI.js
2026-04-21 20:36:08 -07:00

441 lines
14 KiB
JavaScript

const Path = require('path')
const axios = require('axios')
const Database = require('../Database')
const Logger = require('../Logger')
const htmlSanitizer = require('../utils/htmlSanitizer')
const DEFAULT_BASE_URL = 'https://api.openai.com/v1'
const DEFAULT_MODEL = 'gpt-5.4-mini'
const RESPONSE_TIMEOUT_MS = 60000
const SEQUENCE_REGEX = /^(?:0|[1-9]\d{0,3})(?:\.\d{1,2})?$/
class OpenAI {
summarizeBookForLog(book) {
return JSON.stringify({
id: book.id,
title: book.title,
authors: book.authors,
fullPath: book.fullPath,
relPath: book.relPath,
existingSeries: book.existingSeries,
currentSequence: book.currentSequence
})
}
summarizeAssignmentForLog(assignment) {
return JSON.stringify({
id: assignment.id,
seriesName: assignment.seriesName || null,
sequence: assignment.sequence || null,
reason: assignment.reason || ''
})
}
normalizePathForPrompt(filePath) {
if (!filePath || typeof filePath !== 'string') return null
return filePath.replace(/\\/g, '/')
}
getFolderContext(libraryItem) {
const absolutePath = this.normalizePathForPrompt(libraryItem.path)
const relativePath = this.normalizePathForPrompt(libraryItem.relPath)
const basePath = relativePath || absolutePath
if (!basePath && !absolutePath) {
return {
fullPath: null,
relPath: null,
itemFolderName: null,
parentFolderName: null,
folderHierarchy: [],
fullPathHierarchy: []
}
}
const itemPath = libraryItem.isFile ? Path.posix.dirname(basePath) : basePath
const absoluteItemPath = absolutePath ? (libraryItem.isFile ? Path.posix.dirname(absolutePath) : absolutePath) : null
const folderHierarchy = itemPath
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
const fullPathHierarchy = absoluteItemPath
? absoluteItemPath
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
: []
const itemFolderName = folderHierarchy.length ? folderHierarchy[folderHierarchy.length - 1] : null
const parentFolderName = folderHierarchy.length > 1 ? folderHierarchy[folderHierarchy.length - 2] : null
return {
fullPath: absolutePath,
relPath: relativePath || basePath,
itemFolderName,
parentFolderName,
folderHierarchy,
fullPathHierarchy
}
}
normalizeBaseURL(url) {
return (url || DEFAULT_BASE_URL).replace(/\/+$/, '')
}
get apiKey() {
return Database.serverSettings?.openAIResolvedApiKey || null
}
get baseURL() {
return this.normalizeBaseURL(Database.serverSettings?.openAIResolvedBaseURL || DEFAULT_BASE_URL)
}
get model() {
return Database.serverSettings?.openAIResolvedModel || DEFAULT_MODEL
}
get isConfigured() {
return !!this.apiKey
}
cleanDescription(description) {
if (!description || typeof description !== 'string') return null
const plain = htmlSanitizer.stripAllTags(description).replace(/\s+/g, ' ').trim()
if (!plain) return null
return plain.slice(0, 600)
}
buildBookPayload(libraryItem) {
const book = libraryItem.media
const metadata = book.oldMetadataToJSON()
const folderContext = this.getFolderContext(libraryItem)
return {
id: libraryItem.id,
title: metadata.title || null,
subtitle: metadata.subtitle || null,
publishedYear: metadata.publishedYear || null,
authors: (metadata.authors || []).map((author) => author.name),
description: this.cleanDescription(metadata.description),
fullPath: folderContext.fullPath,
relPath: folderContext.relPath,
itemFolderName: folderContext.itemFolderName,
parentFolderName: folderContext.parentFolderName,
folderHierarchy: folderContext.folderHierarchy,
fullPathHierarchy: folderContext.fullPathHierarchy,
existingSeries: (metadata.series || []).map((series) => ({
name: series.name,
sequence: series.sequence || null
}))
}
}
extractTextFromResponse(data) {
if (typeof data?.output_text === 'string' && data.output_text.trim()) {
return data.output_text.trim()
}
const texts = []
for (const outputItem of data?.output || []) {
if (typeof outputItem?.text === 'string' && outputItem.text.trim()) {
texts.push(outputItem.text.trim())
}
for (const contentItem of outputItem?.content || []) {
if (typeof contentItem?.text === 'string' && contentItem.text.trim()) {
texts.push(contentItem.text.trim())
} else if (typeof contentItem?.output_text === 'string' && contentItem.output_text.trim()) {
texts.push(contentItem.output_text.trim())
}
}
}
return texts.join('\n').trim()
}
parseJsonResponse(text) {
if (!text || typeof text !== 'string') {
throw new Error('OpenAI returned an empty response')
}
let cleanedText = text.trim()
const fencedMatch = cleanedText.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
if (fencedMatch) {
cleanedText = fencedMatch[1].trim()
}
try {
return JSON.parse(cleanedText)
} catch (error) {
const start = cleanedText.indexOf('{')
const end = cleanedText.lastIndexOf('}')
if (start >= 0 && end > start) {
return JSON.parse(cleanedText.slice(start, end + 1))
}
throw error
}
}
normalizeSeriesName(value) {
if (!value || typeof value !== 'string') return null
const seriesName = value.trim()
return seriesName || null
}
normalizeSequence(value) {
if (value === null || value === undefined) return null
if (typeof value === 'number') value = String(value)
if (typeof value !== 'string') return null
const sequence = value.trim()
if (!SEQUENCE_REGEX.test(sequence)) return null
return sequence
}
validateBookIds(resultBooks, books) {
if (!Array.isArray(resultBooks) || resultBooks.length !== books.length) {
throw new Error('OpenAI returned an invalid number of books')
}
const expectedIds = new Set(books.map((book) => book.id))
const seenIds = new Set()
resultBooks.forEach((book) => {
if (!expectedIds.has(book?.id)) {
throw new Error(`OpenAI returned an unknown book id "${book?.id}"`)
}
if (seenIds.has(book.id)) {
throw new Error(`OpenAI returned duplicate book id "${book.id}"`)
}
seenIds.add(book.id)
})
}
validateSeriesOrderPayload(payload, books) {
const resultBooks = payload?.books
this.validateBookIds(resultBooks, books)
const sequences = new Set()
return resultBooks
.map((book) => {
const sequence = this.normalizeSequence(book.sequence)
if (!sequence) {
throw new Error(`OpenAI returned an invalid sequence for "${book.id}"`)
}
if (sequences.has(sequence)) {
throw new Error(`OpenAI returned a duplicate sequence "${sequence}"`)
}
sequences.add(sequence)
return {
id: book.id,
sequence,
reason: typeof book.reason === 'string' ? book.reason.trim() : ''
}
})
.sort((a, b) => Number(a.sequence) - Number(b.sequence))
}
validateSeriesDetectionPayload(payload, books) {
const resultBooks = payload?.books
this.validateBookIds(resultBooks, books)
const seriesSequences = new Map()
return resultBooks.map((book) => {
const seriesName = this.normalizeSeriesName(book.seriesName)
const sequence = this.normalizeSequence(book.sequence)
const reason = typeof book.reason === 'string' ? book.reason.trim() : ''
if (seriesName && !sequence) {
Logger.warn(`[OpenAI] Series "${seriesName}" for book "${book.id}" did not include a valid sequence - skipping assignment`)
return {
id: book.id,
seriesName: null,
sequence: null,
reason: reason ? `${reason} (skipped due to missing or invalid sequence)` : 'Skipped due to missing or invalid sequence'
}
}
if (!seriesName && sequence) {
Logger.warn(`[OpenAI] Sequence "${sequence}" for book "${book.id}" did not include a series name - skipping assignment`)
return {
id: book.id,
seriesName: null,
sequence: null,
reason: reason ? `${reason} (skipped due to missing series name)` : 'Skipped due to missing series name'
}
}
if (seriesName && sequence) {
const key = seriesName.toLowerCase()
if (!seriesSequences.has(key)) {
seriesSequences.set(key, new Set())
}
if (seriesSequences.get(key).has(sequence)) {
Logger.warn(`[OpenAI] Duplicate inferred sequence "${sequence}" inside "${seriesName}" for book "${book.id}" - skipping assignment`)
return {
id: book.id,
seriesName: null,
sequence: null,
reason: reason ? `${reason} (skipped due to duplicate inferred sequence)` : 'Skipped due to duplicate inferred sequence'
}
}
seriesSequences.get(key).add(sequence)
}
return {
id: book.id,
seriesName,
sequence,
reason
}
})
}
async createResponse(prompt) {
if (!this.isConfigured) {
throw new Error('OpenAI API key is not configured')
}
const url = `${this.baseURL}/responses`
Logger.debug(`[OpenAI] Requesting ${url} with model "${this.model}"`)
const response = await axios
.post(
url,
{
model: this.model,
input: prompt
},
{
timeout: RESPONSE_TIMEOUT_MS,
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
}
)
.catch((error) => {
const status = error.response?.status
const message = error.response?.data?.error?.message || error.message
Logger.error(`[OpenAI] Responses API request failed (${status || 'no-status'})`, message)
if (status === 401) {
throw new Error('OpenAI rejected the API key')
} else if (status === 429) {
throw new Error('OpenAI rate limit reached')
} else if (status) {
throw new Error(`OpenAI request failed with status ${status}`)
}
throw new Error(`OpenAI request failed: ${message}`)
})
const text = this.extractTextFromResponse(response.data)
const parsed = this.parseJsonResponse(text)
Logger.debug(`[OpenAI] Parsed response payload: ${JSON.stringify(parsed)}`)
return parsed
}
async getSeriesOrder(series, libraryItems) {
const books = libraryItems.map((libraryItem) => {
const book = this.buildBookPayload(libraryItem)
const currentSeries = book.existingSeries.find((existingSeries) => existingSeries.name.toLowerCase() === series.name.toLowerCase())
return {
...book,
currentSequence: currentSeries?.sequence || null
}
})
Logger.info(`[OpenAI] Evaluating story order for series "${series.name}" with ${books.length} books`)
books.forEach((book) => {
Logger.info(`[OpenAI] Story-order candidate ${this.summarizeBookForLog(book)}`)
})
const prompt = `You organize audiobooks into the correct in-universe story order for a single series.
Return only valid JSON in this shape:
{
"books": [
{
"id": "library-item-id",
"sequence": "1",
"reason": "brief reason"
}
]
}
Rules:
- Include every provided book exactly once.
- Use numeric string sequences only.
- Sequences must be unique and reflect story order, not shelf order.
- Prefer existing sequence values when they already look plausible.
- If evidence is weak, preserve the current sequence when present; otherwise fall back to publishedYear, then title.
- Do not invent books or series.
Series:
${JSON.stringify({ id: series.id, name: series.name, description: this.cleanDescription(series.description) }, null, 2)}
Books:
${JSON.stringify(books, null, 2)}`
const payload = await this.createResponse(prompt)
const validated = this.validateSeriesOrderPayload(payload, books)
validated.forEach((book) => {
Logger.info(`[OpenAI] Story-order result ${JSON.stringify({ id: book.id, sequence: book.sequence, reason: book.reason || '' })}`)
})
return validated
}
async detectSeriesAssignments(contextLabel, libraryItems, contextType = 'author') {
const books = libraryItems.map((libraryItem) => this.buildBookPayload(libraryItem))
Logger.info(`[OpenAI] Detecting series assignments for ${contextType} "${contextLabel}" with ${books.length} books`)
books.forEach((book) => {
Logger.info(`[OpenAI] Series-detection candidate ${this.summarizeBookForLog(book)}`)
})
const contextDescription =
contextType === 'folder'
? 'These books were grouped because they share the same folder context. Folder structure may be more reliable than author metadata for this group.'
: 'These books were grouped by primary author.'
const contextHeading = contextType === 'folder' ? 'Grouping context' : 'Primary author'
const prompt = `You detect audiobook series membership for a group of related books.
Return only valid JSON in this shape:
{
"books": [
{
"id": "library-item-id",
"seriesName": "Series Name or null",
"sequence": "1 or null",
"reason": "brief reason"
}
]
}
Rules:
- Include every provided book exactly once.
- Use "seriesName": null and "sequence": null for standalones or uncertain books.
- When assigning a series, use a numeric string sequence.
- Reuse an existing series name when it already appears in the provided data.
- Full absolute path and relative path are both available and should be used as evidence.
- Books sharing the same parent folder or series-like folder names are strong evidence they belong together.
- Use folder hierarchy as evidence alongside title, subtitle, description, and existing metadata.
- Do not invent series when the evidence is weak.
- Existing series metadata is trusted context and should usually be preserved.
${contextHeading}:
${JSON.stringify(contextLabel)}
Context note:
${contextDescription}
Books:
${JSON.stringify(books, null, 2)}`
const payload = await this.createResponse(prompt)
const validated = this.validateSeriesDetectionPayload(payload, books)
validated.forEach((assignment) => {
Logger.info(`[OpenAI] Series-detection result ${this.summarizeAssignmentForLog(assignment)}`)
})
return validated
}
}
module.exports = OpenAI