mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-12 22:41:29 +00:00
Add OpenAI series evaluation
This commit is contained in:
parent
5b2a788cfc
commit
77206d90cb
11 changed files with 1107 additions and 2 deletions
|
|
@ -134,6 +134,13 @@ export default {
|
|||
}
|
||||
]
|
||||
|
||||
if (this.userCanUpdate && this.openAIConfigured) {
|
||||
items.push({
|
||||
text: 'Organize Story Order With AI',
|
||||
action: 'organize-story-order-with-ai'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
|
||||
items.push({
|
||||
text: this.$strings.LabelOpenRSSFeed,
|
||||
|
|
@ -221,6 +228,9 @@ export default {
|
|||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
openAIConfigured() {
|
||||
return !!this.$store.getters['getServerSetting']('openAIConfigured')
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
|
|
@ -427,6 +437,12 @@ export default {
|
|||
seriesContextMenuAction({ action }) {
|
||||
if (action === 'open-rss-feed') {
|
||||
this.showOpenSeriesRSSFeed()
|
||||
} else if (action === 'organize-story-order-with-ai') {
|
||||
if (this.processingSeries) {
|
||||
console.warn('Already processing series')
|
||||
return
|
||||
}
|
||||
this.organizeStoryOrderWithAI()
|
||||
} else if (action === 're-add-to-continue-listening') {
|
||||
if (this.processingSeries) {
|
||||
console.warn('Already processing series')
|
||||
|
|
@ -453,6 +469,35 @@ export default {
|
|||
feed: this.selectedSeries.rssFeed
|
||||
})
|
||||
},
|
||||
organizeStoryOrderWithAI() {
|
||||
const payload = {
|
||||
message: `Organize "${this.selectedSeries.name}" into AI-detected story order? This will update the series sequence on every book in the series.`,
|
||||
callback: (confirmed) => {
|
||||
if (!confirmed) return
|
||||
|
||||
this.processingSeries = true
|
||||
this.$axios
|
||||
.$post(`/api/series/${this.seriesId}/organize-story-order`)
|
||||
.then((data) => {
|
||||
if (!data.updated) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
} else {
|
||||
this.$toast.success(`Updated story order for ${data.updated} books`)
|
||||
}
|
||||
this.$eventBus.$emit('series-books-updated', { seriesId: this.seriesId })
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to organize story order with AI', error)
|
||||
this.$toast.error(error.response?.data || this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingSeries = false
|
||||
})
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
reAddSeriesToContinueListening() {
|
||||
this.processingSeries = true
|
||||
this.$axios
|
||||
|
|
|
|||
|
|
@ -602,6 +602,11 @@ export default {
|
|||
this.libraryItemUpdated(ab)
|
||||
})
|
||||
},
|
||||
seriesBooksUpdated(payload) {
|
||||
if (this.entityName !== 'series-books') return
|
||||
if (payload?.seriesId !== this.seriesId) return
|
||||
this.resetEntities(this.currScrollTop)
|
||||
},
|
||||
collectionAdded(collection) {
|
||||
if (this.entityName !== 'collections') return
|
||||
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
||||
|
|
@ -791,6 +796,7 @@ export default {
|
|||
|
||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('series-books-updated', this.seriesBooksUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
|
|
@ -822,6 +828,7 @@ export default {
|
|||
|
||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('series-books-updated', this.seriesBooksUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
<template>
|
||||
<div class="w-full h-full px-1 md:px-2 py-1 mb-4">
|
||||
<div v-if="isBookLibrary" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">Detect Missing Series With AI</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Analyze books in this library and add missing series names and sequence values using OpenAI. Use the full re-evaluation option after editing book metadata and you want existing series assignments reconsidered.</p>
|
||||
</div>
|
||||
<div class="grow" />
|
||||
<div>
|
||||
<ui-btn class="mb-3 block" :disabled="processing || !openAIConfigured" @click.stop="detectSeriesWithAI">Detect Missing Series</ui-btn>
|
||||
<ui-btn :disabled="processing || !openAIConfigured" @click.stop="reEvaluateSeriesWithAI">Re-evaluate All Series</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!openAIConfigured" class="text-sm text-yellow-400 mt-3">Configure OpenAI first in server settings.</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
|
|
@ -38,9 +53,55 @@ export default {
|
|||
},
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
},
|
||||
openAIConfigured() {
|
||||
return !!this.$store.getters['getServerSetting']('openAIConfigured')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
detectSeriesWithAI() {
|
||||
const payload = {
|
||||
message: 'Detect missing series in this library with AI? This only fills books that currently have no series metadata.',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.runSeriesDetection(true)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
reEvaluateSeriesWithAI() {
|
||||
const payload = {
|
||||
message: 'Re-evaluate all books in this library with AI? This can update sequence values for books that already have series metadata.',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.runSeriesDetection(false)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
runSeriesDetection(onlyMissing = true) {
|
||||
this.$emit('update:processing', true)
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.libraryId}/detect-series-with-ai?onlyMissing=${onlyMissing ? 1 : 0}`)
|
||||
.then((data) => {
|
||||
if (!data.updated) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
} else {
|
||||
this.$toast.success(onlyMissing ? `AI added series data to ${data.updated} books` : `AI re-evaluated series data for ${data.updated} books`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to detect series with AI', error)
|
||||
this.$toast.error(error.response?.data || this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
},
|
||||
removeAllMetadataClick(ext) {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),
|
||||
|
|
|
|||
|
|
@ -151,6 +151,38 @@
|
|||
<div class="py-2">
|
||||
<ui-multi-select v-model="newServerSettings.allowedOrigins" :items="newServerSettings.allowedOrigins" :label="$strings.LabelCorsAllowed" class="max-w-72" @input="updateCorsOrigins" />
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">AI</h2>
|
||||
</div>
|
||||
|
||||
<div class="border border-white/10 rounded-lg p-4 mt-2">
|
||||
<div class="flex items-center">
|
||||
<p class="text-base font-semibold">OpenAI Series Tools</p>
|
||||
<ui-tooltip text="Environment overrides supported: OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL.">
|
||||
<span class="material-symbols icon-text ml-2 cursor-help">info</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<p class="text-sm text-white/70 mt-2">Use OpenAI to detect missing series in a book library and organize books inside a series into story order.</p>
|
||||
<p class="text-sm text-white/70 mt-2">Status: {{ openAIStatusLabel }}</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<ui-text-input-with-label v-model="openAISettings.openAIModel" :disabled="savingOpenAISettings" label="Model" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<ui-text-input-with-label v-model="openAISettings.openAIBaseURL" :disabled="savingOpenAISettings" label="Base URL" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<ui-text-input-with-label v-model="openAISettings.openAIApiKey" type="password" :disabled="savingOpenAISettings" :placeholder="openAIConfigured ? 'Leave blank to keep current key' : 'sk-...'" label="API Key" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn color="bg-success" small :loading="savingOpenAISettings" @click="saveOpenAISettings">Save AI Settings</ui-btn>
|
||||
<ui-btn v-if="serverSettings && serverSettings.openAIConfigurationSource === 'settings'" color="bg-bg" small class="ml-2" :loading="savingOpenAISettings" @click="clearOpenAISettingsKey">Clear Saved Key</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
|
@ -232,7 +264,13 @@ export default {
|
|||
hasPrefixesChanged: false,
|
||||
newServerSettings: {},
|
||||
showConfirmPurgeCache: false,
|
||||
savingPrefixes: false
|
||||
savingPrefixes: false,
|
||||
savingOpenAISettings: false,
|
||||
openAISettings: {
|
||||
openAIModel: 'gpt-5.4-mini',
|
||||
openAIBaseURL: 'https://api.openai.com/v1',
|
||||
openAIApiKey: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -256,6 +294,14 @@ export default {
|
|||
timeFormats() {
|
||||
return this.$store.state.globals.timeFormats
|
||||
},
|
||||
openAIConfigured() {
|
||||
return !!this.serverSettings?.openAIConfigured
|
||||
},
|
||||
openAIStatusLabel() {
|
||||
if (!this.serverSettings?.openAIConfigured) return 'Not configured'
|
||||
if (this.serverSettings.openAIConfigurationSource === 'environment') return 'Configured from environment variables'
|
||||
return 'Configured in server settings'
|
||||
},
|
||||
dateExample() {
|
||||
const date = new Date(2014, 2, 25)
|
||||
return this.$formatJsDate(date, this.newServerSettings.dateFormat)
|
||||
|
|
@ -362,11 +408,68 @@ export default {
|
|||
}
|
||||
})
|
||||
},
|
||||
saveOpenAISettings() {
|
||||
const openAIModel = this.openAISettings.openAIModel.trim()
|
||||
const openAIBaseURL = this.openAISettings.openAIBaseURL.trim().replace(/\/+$/, '')
|
||||
const payload = {
|
||||
openAIModel,
|
||||
openAIBaseURL
|
||||
}
|
||||
|
||||
if (!openAIModel) {
|
||||
this.$toast.error('OpenAI model is required')
|
||||
return
|
||||
}
|
||||
if (!openAIBaseURL) {
|
||||
this.$toast.error('OpenAI base URL is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(openAIBaseURL)
|
||||
} catch {
|
||||
this.$toast.error('OpenAI base URL must be a valid URL')
|
||||
return
|
||||
}
|
||||
|
||||
const openAIApiKey = this.openAISettings.openAIApiKey.trim()
|
||||
if (openAIApiKey) {
|
||||
payload.openAIApiKey = openAIApiKey
|
||||
}
|
||||
|
||||
this.savingOpenAISettings = true
|
||||
this.$store.dispatch('updateServerSettings', payload).then((response) => {
|
||||
if (response.error) {
|
||||
this.$toast.error(response.error)
|
||||
} else {
|
||||
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
|
||||
this.initServerSettings()
|
||||
}
|
||||
this.savingOpenAISettings = false
|
||||
})
|
||||
},
|
||||
clearOpenAISettingsKey() {
|
||||
this.savingOpenAISettings = true
|
||||
this.$store.dispatch('updateServerSettings', { openAIApiKey: null }).then((response) => {
|
||||
if (response.error) {
|
||||
this.$toast.error(response.error)
|
||||
} else {
|
||||
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
|
||||
this.initServerSettings()
|
||||
}
|
||||
this.savingOpenAISettings = false
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||
this.newServerSettings.allowedOrigins = [...(this.newServerSettings.allowedOrigins || [])]
|
||||
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
|
||||
this.openAISettings = {
|
||||
openAIModel: this.serverSettings?.openAIModel || 'gpt-5.4-mini',
|
||||
openAIBaseURL: this.serverSettings?.openAIBaseURL || 'https://api.openai.com/v1',
|
||||
openAIApiKey: ''
|
||||
}
|
||||
|
||||
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
428
server/providers/OpenAI.js
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
83
test/server/providers/OpenAI.test.js
Normal file
83
test/server/providers/OpenAI.test.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
const { expect } = require('chai')
|
||||
const OpenAI = require('../../../server/providers/OpenAI')
|
||||
|
||||
describe('OpenAI', () => {
|
||||
let openAI
|
||||
|
||||
beforeEach(() => {
|
||||
openAI = new OpenAI()
|
||||
})
|
||||
|
||||
describe('parseJsonResponse', () => {
|
||||
it('parses fenced JSON', () => {
|
||||
const payload = openAI.parseJsonResponse('```json\n{"books":[{"id":"1","sequence":"1"}]}\n```')
|
||||
expect(payload.books).to.have.length(1)
|
||||
expect(payload.books[0].id).to.equal('1')
|
||||
})
|
||||
|
||||
it('parses JSON wrapped in extra text', () => {
|
||||
const payload = openAI.parseJsonResponse('Result:\n{"books":[{"id":"1","sequence":"1"}]}')
|
||||
expect(payload.books[0].sequence).to.equal('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSeriesOrderPayload', () => {
|
||||
it('normalizes valid ordered books', () => {
|
||||
const result = openAI.validateSeriesOrderPayload(
|
||||
{
|
||||
books: [
|
||||
{ id: 'b', sequence: '2' },
|
||||
{ id: 'a', sequence: '1' }
|
||||
]
|
||||
},
|
||||
[{ id: 'a' }, { id: 'b' }]
|
||||
)
|
||||
|
||||
expect(result.map((book) => book.id)).to.deep.equal(['a', 'b'])
|
||||
})
|
||||
|
||||
it('rejects duplicate sequences', () => {
|
||||
expect(() =>
|
||||
openAI.validateSeriesOrderPayload(
|
||||
{
|
||||
books: [
|
||||
{ id: 'a', sequence: '1' },
|
||||
{ id: 'b', sequence: '1' }
|
||||
]
|
||||
},
|
||||
[{ id: 'a' }, { id: 'b' }]
|
||||
)
|
||||
).to.throw('duplicate sequence')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSeriesDetectionPayload', () => {
|
||||
it('allows null standalone assignments', () => {
|
||||
const result = openAI.validateSeriesDetectionPayload(
|
||||
{
|
||||
books: [
|
||||
{ id: 'a', seriesName: null, sequence: null },
|
||||
{ id: 'b', seriesName: 'Series Name', sequence: '1.5' }
|
||||
]
|
||||
},
|
||||
[{ id: 'a' }, { id: 'b' }]
|
||||
)
|
||||
|
||||
expect(result[0].seriesName).to.equal(null)
|
||||
expect(result[1].sequence).to.equal('1.5')
|
||||
})
|
||||
|
||||
it('rejects a series assignment without sequence', () => {
|
||||
expect(() =>
|
||||
openAI.validateSeriesDetectionPayload(
|
||||
{
|
||||
books: [
|
||||
{ id: 'a', seriesName: 'Series Name', sequence: null }
|
||||
]
|
||||
},
|
||||
[{ id: 'a' }]
|
||||
)
|
||||
).to.throw('without a valid sequence')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue