Add OpenAI series evaluation

This commit is contained in:
korjik 2026-04-21 19:38:34 -07:00
parent 5b2a788cfc
commit 77206d90cb
11 changed files with 1107 additions and 2 deletions

View file

@ -19,12 +19,15 @@ const Scanner = require('../scanner/Scanner')
const Database = require('../Database')
const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const OpenAI = require('../providers/OpenAI')
const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters')
const zipHelpers = require('../utils/zipHelpers')
const openAI = new OpenAI()
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
@ -1463,6 +1466,246 @@ class LibraryController {
}
}
async getLibraryBooksForAISeriesDetection(libraryId) {
const books = await Database.bookModel.findAll({
include: [
{
model: Database.libraryItemModel,
required: true,
where: {
libraryId,
isMissing: false,
isInvalid: false
}
},
{
model: Database.bookAuthorModel,
include: {
model: Database.authorModel
},
separate: true,
order: [['createdAt', 'ASC']]
},
{
model: Database.bookSeriesModel,
include: {
model: Database.seriesModel
},
separate: true,
order: [['createdAt', 'ASC']]
}
],
order: [['title', 'ASC']]
})
return books.map((book) => {
const libraryItem = book.libraryItem
delete book.dataValues.libraryItem
book.authors = book.bookAuthors?.map((bookAuthor) => bookAuthor.author) || []
delete book.dataValues.bookAuthors
book.series =
book.bookSeries?.map((bookSeries) => {
const series = bookSeries.series
delete bookSeries.dataValues.series
series.bookSeries = bookSeries
return series
}) || []
delete book.dataValues.bookSeries
libraryItem.media = book
return libraryItem
})
}
groupLibraryBooksByPrimaryAuthor(libraryItems) {
const groups = new Map()
for (const libraryItem of libraryItems) {
const primaryAuthor = libraryItem.media.authors?.[0]?.name?.trim()
if (!primaryAuthor) continue
const key = primaryAuthor.toLowerCase()
if (!groups.has(key)) {
groups.set(key, {
authorName: primaryAuthor,
libraryItems: []
})
}
groups.get(key).libraryItems.push(libraryItem)
}
return [...groups.values()]
}
getLibraryItemFolderKey(libraryItem) {
const basePath = (libraryItem.relPath || libraryItem.path || '').replace(/\\/g, '/')
if (!basePath) return null
const itemPath = libraryItem.isFile ? Path.posix.dirname(basePath) : basePath
const parentPath = Path.posix.dirname(itemPath)
if (!parentPath || parentPath === '.' || parentPath === '/') return null
return parentPath.toLowerCase()
}
groupLibraryBooksByFolder(libraryItems) {
const groups = new Map()
for (const libraryItem of libraryItems) {
const folderKey = LibraryController.prototype.getLibraryItemFolderKey.call(this, libraryItem)
if (!folderKey) continue
if (!groups.has(folderKey)) {
groups.set(folderKey, {
label: folderKey,
libraryItems: []
})
}
groups.get(folderKey).libraryItems.push(libraryItem)
}
return [...groups.values()]
}
/**
* POST: /api/libraries/:id/detect-series-with-ai
*
* @param {LibraryControllerRequest} req
* @param {Response} res
*/
async detectSeriesWithAI(req, res) {
if (!req.user.canUpdate) {
Logger.warn(`[LibraryController] User "${req.user.username}" attempted AI series detection without update permissions`)
return res.sendStatus(403)
}
if (req.library.mediaType !== 'book') {
return res.status(400).send('AI series detection is only available for book libraries')
}
if (!openAI.isConfigured) {
return res.status(400).send('OpenAI is not configured')
}
try {
const onlyMissingSeries = req.query.onlyMissing !== '0'
const libraryItems = await LibraryController.prototype.getLibraryBooksForAISeriesDetection.call(this, req.library.id)
const authorGroups = LibraryController.prototype.groupLibraryBooksByPrimaryAuthor.call(this, libraryItems).filter(({ libraryItems }) => {
if (libraryItems.length < 2) return false
if (!onlyMissingSeries) return true
return libraryItems.some((libraryItem) => !libraryItem.media.series.length)
})
const authorCoveredEligibleIds = new Set()
authorGroups.forEach((authorGroup) => {
authorGroup.libraryItems.forEach((libraryItem) => {
if (!onlyMissingSeries || !libraryItem.media.series.length) {
authorCoveredEligibleIds.add(libraryItem.id)
}
})
})
const folderGroups = LibraryController.prototype.groupLibraryBooksByFolder
.call(this, libraryItems)
.filter(({ libraryItems }) => {
if (libraryItems.length < 2) return false
const eligibleItems = onlyMissingSeries ? libraryItems.filter((libraryItem) => !libraryItem.media.series.length) : libraryItems
if (!eligibleItems.length) return false
return eligibleItems.some((libraryItem) => !authorCoveredEligibleIds.has(libraryItem.id))
})
const evaluationGroups = [
...authorGroups.map((group) => ({ type: 'author', label: group.authorName, libraryItems: group.libraryItems })),
...folderGroups.map((group) => ({ type: 'folder', label: group.label, libraryItems: group.libraryItems }))
]
let groupsProcessed = 0
let booksConsidered = 0
let booksUpdated = 0
for (const evaluationGroup of evaluationGroups) {
const eligibleLibraryItems = onlyMissingSeries ? evaluationGroup.libraryItems.filter((libraryItem) => !libraryItem.media.series.length) : evaluationGroup.libraryItems
if (!eligibleLibraryItems.length) continue
if (evaluationGroup.type === 'folder') {
const remainingEligibleItems = eligibleLibraryItems.filter((libraryItem) => !authorCoveredEligibleIds.has(libraryItem.id))
if (!remainingEligibleItems.length) continue
Logger.info(
`[LibraryController] AI series detection evaluating folder group "${evaluationGroup.label}" with ${evaluationGroup.libraryItems.length} books (${remainingEligibleItems.length} eligible for update)`
)
evaluationGroup.eligibleLibraryItems = remainingEligibleItems
} else {
Logger.info(
`[LibraryController] AI series detection evaluating author "${evaluationGroup.label}" with ${evaluationGroup.libraryItems.length} books (${eligibleLibraryItems.length} eligible for update)`
)
evaluationGroup.eligibleLibraryItems = eligibleLibraryItems
}
const assignments = await openAI.detectSeriesAssignments(evaluationGroup.label, evaluationGroup.libraryItems, evaluationGroup.type)
const assignmentsByLibraryItemId = new Map(assignments.map((assignment) => [assignment.id, assignment]))
groupsProcessed++
for (const libraryItem of evaluationGroup.eligibleLibraryItems) {
booksConsidered++
const assignment = assignmentsByLibraryItemId.get(libraryItem.id)
if (!assignment?.seriesName || !assignment.sequence) {
Logger.info(`[LibraryController] AI series detection skipped "${libraryItem.media.title}" (${libraryItem.id})`)
continue
}
Logger.info(
`[LibraryController] AI series detection applying "${libraryItem.media.title}" (${libraryItem.id}) -> series "${assignment.seriesName}" sequence "${assignment.sequence}"`
)
const existingSeries = libraryItem.media.series.find((series) => series.name.toLowerCase() === assignment.seriesName.toLowerCase())
const seriesPayload = libraryItem.media.series.map((series) => ({
id: series.id,
name: series.name,
sequence: series.bookSeries?.sequence || null
}))
if (existingSeries) {
const existingSeriesIndex = seriesPayload.findIndex((series) => series.id === existingSeries.id)
if (existingSeriesIndex >= 0) {
seriesPayload[existingSeriesIndex].sequence = assignment.sequence
}
} else {
seriesPayload.push({
name: assignment.seriesName,
sequence: assignment.sequence
})
}
const seriesUpdate = await libraryItem.media.updateSeriesFromRequest(seriesPayload, libraryItem.libraryId)
if (!seriesUpdate?.hasUpdates) {
Logger.info(`[LibraryController] AI series detection found no metadata changes for "${libraryItem.media.title}" (${libraryItem.id})`)
continue
}
if (seriesUpdate.seriesAdded?.length) {
seriesUpdate.seriesAdded.forEach((series) => {
Database.addSeriesToFilterData(req.library.id, series.name, series.id)
})
}
libraryItem.changed('updatedAt', true)
await libraryItem.save()
await libraryItem.saveMetadataFile()
booksUpdated++
SocketAuthority.libraryItemEmitter('item_updated', libraryItem)
}
}
Logger.info(
`[LibraryController] AI series detection completed for library "${req.library.name}" - groupsProcessed=${groupsProcessed}, booksConsidered=${booksConsidered}, booksUpdated=${booksUpdated}`
)
res.json({
groupsProcessed,
booksConsidered,
updated: booksUpdated
})
} catch (error) {
Logger.error(`[LibraryController] Failed AI series detection for library "${req.library.name}"`, error)
res.status(500).send(error.message || 'Failed to detect series with AI')
}
}
/**
*
* @param {RequestWithUser} req

View file

@ -145,6 +145,36 @@ class MiscController {
if (settingsUpdate.allowedOrigins && !Array.isArray(settingsUpdate.allowedOrigins)) {
return res.status(400).send('allowedOrigins must be an array')
}
if (settingsUpdate.openAIApiKey !== undefined && settingsUpdate.openAIApiKey !== null && typeof settingsUpdate.openAIApiKey !== 'string') {
return res.status(400).send('openAIApiKey must be a string or null')
}
if (settingsUpdate.openAIBaseURL !== undefined && typeof settingsUpdate.openAIBaseURL !== 'string') {
return res.status(400).send('openAIBaseURL must be a string')
}
if (settingsUpdate.openAIModel !== undefined && typeof settingsUpdate.openAIModel !== 'string') {
return res.status(400).send('openAIModel must be a string')
}
if (typeof settingsUpdate.openAIApiKey === 'string') {
settingsUpdate.openAIApiKey = settingsUpdate.openAIApiKey.trim() || null
}
if (typeof settingsUpdate.openAIBaseURL === 'string') {
settingsUpdate.openAIBaseURL = settingsUpdate.openAIBaseURL.trim().replace(/\/+$/, '')
if (!settingsUpdate.openAIBaseURL) {
return res.status(400).send('openAIBaseURL is required')
}
try {
new URL(settingsUpdate.openAIBaseURL)
} catch {
return res.status(400).send('openAIBaseURL must be a valid URL')
}
}
if (typeof settingsUpdate.openAIModel === 'string') {
settingsUpdate.openAIModel = settingsUpdate.openAIModel.trim()
if (!settingsUpdate.openAIModel) {
return res.status(400).send('openAIModel is required')
}
}
const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) {

View file

@ -2,11 +2,14 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const OpenAI = require('../providers/OpenAI')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const openAI = new OpenAI()
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
@ -86,6 +89,68 @@ class SeriesController {
res.json(req.series.toOldJSON())
}
/**
* POST: /api/series/:id/organize-story-order
*
* @param {SeriesControllerRequest} req
* @param {Response} res
*/
async organizeStoryOrder(req, res) {
if (!openAI.isConfigured) {
return res.status(400).send('OpenAI is not configured')
}
if (!req.libraryItemsInSeries.length) {
return res.status(400).send('No books found in this series')
}
try {
const seriesOrder = await openAI.getSeriesOrder(req.series, req.libraryItemsInSeries)
const sequenceByLibraryItemId = new Map(seriesOrder.map((book) => [book.id, book.sequence]))
const updatedItems = []
Logger.info(`[SeriesController] AI story-order evaluation returned ${seriesOrder.length} books for series "${req.series.name}"`)
for (const libraryItem of req.libraryItemsInSeries) {
const nextSequence = sequenceByLibraryItemId.get(libraryItem.id)
if (!nextSequence) continue
Logger.info(`[SeriesController] AI story-order applying "${libraryItem.media.title}" (${libraryItem.id}) -> sequence "${nextSequence}" in series "${req.series.name}"`)
const seriesPayload = libraryItem.media.series.map((series) => ({
id: series.id,
name: series.name,
sequence: series.id === req.series.id ? nextSequence : series.bookSeries?.sequence || null
}))
const seriesUpdate = await libraryItem.media.updateSeriesFromRequest(seriesPayload, libraryItem.libraryId)
if (!seriesUpdate?.hasUpdates) {
Logger.info(`[SeriesController] AI story-order found no change for "${libraryItem.media.title}" (${libraryItem.id})`)
continue
}
libraryItem.changed('updatedAt', true)
await libraryItem.save()
await libraryItem.saveMetadataFile()
updatedItems.push(libraryItem)
SocketAuthority.libraryItemEmitter('item_updated', libraryItem)
}
if (updatedItems.length) {
SocketAuthority.emitter('series_updated', req.series.toOldJSON())
}
Logger.info(`[SeriesController] AI story-order completed for series "${req.series.name}" - updated=${updatedItems.length}, total=${req.libraryItemsInSeries.length}`)
res.json({
updated: updatedItems.length,
total: req.libraryItemsInSeries.length
})
} catch (error) {
Logger.error(`[SeriesController] Failed to organize story order for "${req.series.name}"`, error)
res.status(500).send(error.message || 'Failed to organize story order')
}
}
/**
*
* @param {RequestWithUser} req

View file

@ -84,6 +84,11 @@ class ServerSettings {
this.authOpenIDAdvancedPermsClaim = ''
this.authOpenIDSubfolderForRedirectURLs = undefined
// OpenAI
this.openAIApiKey = null
this.openAIBaseURL = 'https://api.openai.com/v1'
this.openAIModel = 'gpt-5.4-mini'
if (settings) {
this.construct(settings)
}
@ -147,6 +152,9 @@ class ServerSettings {
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
this.openAIApiKey = settings.openAIApiKey || null
this.openAIBaseURL = settings.openAIBaseURL || 'https://api.openai.com/v1'
this.openAIModel = settings.openAIModel || 'gpt-5.4-mini'
if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local']
@ -256,7 +264,10 @@ class ServerSettings {
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
openAIApiKey: this.openAIApiKey, // Do not return to client
openAIBaseURL: this.openAIBaseURL,
openAIModel: this.openAIModel
}
}
@ -268,9 +279,36 @@ class ServerSettings {
delete json.authOpenIDMobileRedirectURIs
delete json.authOpenIDGroupClaim
delete json.authOpenIDAdvancedPermsClaim
delete json.openAIApiKey
json.openAIConfigured = this.openAIConfigured
json.openAIConfigurationSource = this.openAIConfigurationSource
json.openAIBaseURL = this.openAIResolvedBaseURL
json.openAIModel = this.openAIResolvedModel
return json
}
get openAIResolvedApiKey() {
return process.env.OPENAI_API_KEY || this.openAIApiKey || null
}
get openAIResolvedBaseURL() {
return process.env.OPENAI_BASE_URL || this.openAIBaseURL || 'https://api.openai.com/v1'
}
get openAIResolvedModel() {
return process.env.OPENAI_MODEL || this.openAIModel || 'gpt-5.4-mini'
}
get openAIConfigured() {
return !!this.openAIResolvedApiKey
}
get openAIConfigurationSource() {
if (process.env.OPENAI_API_KEY) return 'environment'
if (this.openAIApiKey) return 'settings'
return null
}
get supportedAuthMethods() {
return ['local', 'openid']
}

428
server/providers/OpenAI.js Normal file
View file

@ -0,0 +1,428 @@
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)
if (seriesName && !sequence) {
throw new Error(`OpenAI returned a series without a valid sequence for "${book.id}"`)
}
if (!seriesName && sequence) {
throw new Error(`OpenAI returned a sequence without a series for "${book.id}"`)
}
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: typeof book.reason === 'string' ? `${book.reason.trim()} (skipped due to duplicate inferred sequence)` : 'Skipped due to duplicate inferred sequence'
}
}
seriesSequences.get(key).add(sequence)
}
return {
id: book.id,
seriesName,
sequence,
reason: typeof book.reason === 'string' ? book.reason.trim() : ''
}
})
}
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

View file

@ -89,6 +89,7 @@ class ApiRouter {
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.post('/libraries/:id/detect-series-with-ai', LibraryController.middleware.bind(this), LibraryController.detectSeriesWithAI.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
@ -222,6 +223,7 @@ class ApiRouter {
//
this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
this.router.patch('/series/:id', SeriesController.middleware.bind(this), SeriesController.update.bind(this))
this.router.post('/series/:id/organize-story-order', SeriesController.middleware.bind(this), SeriesController.organizeStoryOrder.bind(this))
//
// Playback Session Routes