mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-26 13:21:31 +00:00
Merge 2cfc175c61 into 47ea6b5092
This commit is contained in:
commit
12600ba04c
19 changed files with 2397 additions and 44 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) {
|
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelOpenRSSFeed,
|
text: this.$strings.LabelOpenRSSFeed,
|
||||||
|
|
@ -221,6 +228,9 @@ export default {
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
openAIConfigured() {
|
||||||
|
return !!this.$store.getters['getServerSetting']('openAIConfigured')
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
|
@ -427,6 +437,12 @@ export default {
|
||||||
seriesContextMenuAction({ action }) {
|
seriesContextMenuAction({ action }) {
|
||||||
if (action === 'open-rss-feed') {
|
if (action === 'open-rss-feed') {
|
||||||
this.showOpenSeriesRSSFeed()
|
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') {
|
} else if (action === 're-add-to-continue-listening') {
|
||||||
if (this.processingSeries) {
|
if (this.processingSeries) {
|
||||||
console.warn('Already processing series')
|
console.warn('Already processing series')
|
||||||
|
|
@ -453,6 +469,35 @@ export default {
|
||||||
feed: this.selectedSeries.rssFeed
|
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() {
|
reAddSeriesToContinueListening() {
|
||||||
this.processingSeries = true
|
this.processingSeries = true
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,11 @@ export default {
|
||||||
this.libraryItemUpdated(ab)
|
this.libraryItemUpdated(ab)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
seriesBooksUpdated(payload) {
|
||||||
|
if (this.entityName !== 'series-books') return
|
||||||
|
if (payload?.seriesId !== this.seriesId) return
|
||||||
|
this.resetEntities(this.currScrollTop)
|
||||||
|
},
|
||||||
collectionAdded(collection) {
|
collectionAdded(collection) {
|
||||||
if (this.entityName !== 'collections') return
|
if (this.entityName !== 'collections') return
|
||||||
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
||||||
|
|
@ -791,6 +796,7 @@ export default {
|
||||||
|
|
||||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$on('series-books-updated', this.seriesBooksUpdated)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
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('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$off('series-books-updated', this.seriesBooksUpdated)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ export default {
|
||||||
autoScanCronExpression: null,
|
autoScanCronExpression: null,
|
||||||
hideSingleBookSeries: false,
|
hideSingleBookSeries: false,
|
||||||
onlyShowLaterBooksInContinueSeries: false,
|
onlyShowLaterBooksInContinueSeries: false,
|
||||||
|
openAIDirectoryGrouping: false,
|
||||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'],
|
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'],
|
||||||
markAsFinishedPercentComplete: null,
|
markAsFinishedPercentComplete: null,
|
||||||
markAsFinishedTimeRemaining: 10
|
markAsFinishedTimeRemaining: 10
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||||
|
<div class="flex items-center justify-between md:justify-start mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="openAIDirectoryGrouping" @input="updated" />
|
||||||
|
<p class="pl-4 text-sm text-gray-300">Use OpenAI to interpret poor directory trees during library scans</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<h2 class="text-base md:text-lg text-gray-200">{{ $strings.HeaderMetadataOrderOfPrecedence }}</h2>
|
<h2 class="text-base md:text-lg text-gray-200">{{ $strings.HeaderMetadataOrderOfPrecedence }}</h2>
|
||||||
<ui-btn small @click="resetToDefault">{{ $strings.ButtonResetToDefault }}</ui-btn>
|
<ui-btn small @click="resetToDefault">{{ $strings.ButtonResetToDefault }}</ui-btn>
|
||||||
|
|
@ -66,6 +73,11 @@ export default {
|
||||||
name: 'Audio file meta tags OR ebook metadata',
|
name: 'Audio file meta tags OR ebook metadata',
|
||||||
include: true
|
include: true
|
||||||
},
|
},
|
||||||
|
openAIPathMetadata: {
|
||||||
|
id: 'openAIPathMetadata',
|
||||||
|
name: 'OpenAI path and filename inference',
|
||||||
|
include: false
|
||||||
|
},
|
||||||
nfoFile: {
|
nfoFile: {
|
||||||
id: 'nfoFile',
|
id: 'nfoFile',
|
||||||
name: 'NFO file',
|
name: 'NFO file',
|
||||||
|
|
@ -87,7 +99,8 @@ export default {
|
||||||
include: true
|
include: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
metadataSourceMapped: []
|
metadataSourceMapped: [],
|
||||||
|
openAIDirectoryGrouping: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -126,6 +139,7 @@ export default {
|
||||||
metadataSourceIds.reverse()
|
metadataSourceIds.reverse()
|
||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
|
openAIDirectoryGrouping: !!this.openAIDirectoryGrouping,
|
||||||
metadataPrecedence: metadataSourceIds
|
metadataPrecedence: metadataSourceIds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +154,7 @@ export default {
|
||||||
this.$emit('update', this.getLibraryData())
|
this.$emit('update', this.getLibraryData())
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
this.openAIDirectoryGrouping = !!this.librarySettings.openAIDirectoryGrouping
|
||||||
const metadataPrecedence = this.librarySettings.metadataPrecedence || []
|
const metadataPrecedence = this.librarySettings.metadataPrecedence || []
|
||||||
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
|
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,34 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full px-1 md:px-2 py-1 mb-4">
|
<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 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">Dedupe Books With AI</p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Analyze likely duplicate books in this library with OpenAI, keep the best copy, and remove the duplicate items. This deletes duplicate files from disk.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grow" />
|
||||||
|
<div>
|
||||||
|
<ui-btn :disabled="processing || !openAIConfigured" @click.stop="dedupeBooksWithAI">Dedupe Books</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="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex flex-wrap items-center">
|
<div class="flex flex-wrap items-center">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -38,9 +67,86 @@ export default {
|
||||||
},
|
},
|
||||||
isBookLibrary() {
|
isBookLibrary() {
|
||||||
return this.mediaType === 'book'
|
return this.mediaType === 'book'
|
||||||
|
},
|
||||||
|
openAIConfigured() {
|
||||||
|
return !!this.$store.getters['getServerSetting']('openAIConfigured')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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)
|
||||||
|
},
|
||||||
|
dedupeBooksWithAI() {
|
||||||
|
const payload = {
|
||||||
|
message: 'Deduplicate books in this library with AI? Duplicate items chosen for removal will be deleted from the database and file system.',
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.runBookDedupe()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
runBookDedupe() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/libraries/${this.libraryId}/dedupe-books-with-ai?hard=1`)
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.duplicatesRemoved) {
|
||||||
|
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||||
|
} else {
|
||||||
|
this.$toast.success(`AI removed ${data.duplicatesRemoved} duplicate books`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to dedupe books with AI', error)
|
||||||
|
this.$toast.error(error.response?.data || this.$strings.ToastFailedToUpdate)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
})
|
||||||
|
},
|
||||||
removeAllMetadataClick(ext) {
|
removeAllMetadataClick(ext) {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),
|
message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,38 @@
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<ui-multi-select v-model="newServerSettings.allowedOrigins" :items="newServerSettings.allowedOrigins" :label="$strings.LabelCorsAllowed" class="max-w-72" @input="updateCorsOrigins" />
|
<ui-multi-select v-model="newServerSettings.allowedOrigins" :items="newServerSettings.allowedOrigins" :label="$strings.LabelCorsAllowed" class="max-w-72" @input="updateCorsOrigins" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
|
|
@ -232,7 +264,13 @@ export default {
|
||||||
hasPrefixesChanged: false,
|
hasPrefixesChanged: false,
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
showConfirmPurgeCache: false,
|
showConfirmPurgeCache: false,
|
||||||
savingPrefixes: false
|
savingPrefixes: false,
|
||||||
|
savingOpenAISettings: false,
|
||||||
|
openAISettings: {
|
||||||
|
openAIModel: 'gpt-5.4-mini',
|
||||||
|
openAIBaseURL: 'https://api.openai.com/v1',
|
||||||
|
openAIApiKey: ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -256,6 +294,14 @@ export default {
|
||||||
timeFormats() {
|
timeFormats() {
|
||||||
return this.$store.state.globals.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() {
|
dateExample() {
|
||||||
const date = new Date(2014, 2, 25)
|
const date = new Date(2014, 2, 25)
|
||||||
return this.$formatJsDate(date, this.newServerSettings.dateFormat)
|
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() {
|
initServerSettings() {
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||||
this.newServerSettings.allowedOrigins = [...(this.newServerSettings.allowedOrigins || [])]
|
this.newServerSettings.allowedOrigins = [...(this.newServerSettings.allowedOrigins || [])]
|
||||||
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
|
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.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||||
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
||||||
|
|
|
||||||
37
readme.md
37
readme.md
|
|
@ -29,6 +29,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
|
||||||
- Progressive Web App (PWA)
|
- Progressive Web App (PWA)
|
||||||
- Chromecast support on the web app and android app
|
- Chromecast support on the web app and android app
|
||||||
- Fetch metadata and cover art from several sources
|
- Fetch metadata and cover art from several sources
|
||||||
|
- OpenAI-assisted library tools for series detection, story ordering, scan-time metadata inference, poor directory-tree interpretation, and duplicate-book cleanup
|
||||||
- Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
|
- Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
|
||||||
- Merge your audio files into a single m4b
|
- Merge your audio files into a single m4b
|
||||||
- Embed metadata and cover image into your audio files
|
- Embed metadata and cover image into your audio files
|
||||||
|
|
@ -73,6 +74,42 @@ Check out the [API documentation](https://api.audiobookshelf.org/)
|
||||||
|
|
||||||
See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
||||||
|
|
||||||
|
### OpenAI-assisted library organization
|
||||||
|
|
||||||
|
Audiobookshelf can optionally use OpenAI to help organize difficult audiobook libraries when filenames, folders, and metadata are inconsistent.
|
||||||
|
|
||||||
|
Available OpenAI features in this branch:
|
||||||
|
|
||||||
|
- Detect missing series assignments for books in a library
|
||||||
|
- Re-evaluate existing series assignments after metadata changes
|
||||||
|
- Organize books inside a series into story order
|
||||||
|
- Infer book metadata during scans from messy paths, filenames, and embedded tag data
|
||||||
|
- Interpret poor directory trees during scans to split ambiguous folders into better logical library items
|
||||||
|
- Detect likely duplicate books in library tools and remove duplicate copies
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
|
||||||
|
- Open the web app and go to `Settings -> AI`
|
||||||
|
- Enter your OpenAI API key, model, and base URL
|
||||||
|
- Environment overrides are also supported through `OPENAI_API_KEY`, `OPENAI_MODEL`, and `OPENAI_BASE_URL`
|
||||||
|
|
||||||
|
Library usage:
|
||||||
|
|
||||||
|
- `Config -> Libraries -> Edit Library -> Scanner`
|
||||||
|
- Enable `OpenAI path and filename inference` to let scans infer metadata from weak path structure
|
||||||
|
- Enable `Use OpenAI to interpret poor directory trees during library scans` to let scans group difficult folder layouts more intelligently
|
||||||
|
|
||||||
|
Library tools:
|
||||||
|
|
||||||
|
- `Config -> Libraries -> Edit Library -> Tools`
|
||||||
|
- `Detect Missing Series`
|
||||||
|
- `Re-evaluate All Series`
|
||||||
|
- `Dedupe Books`
|
||||||
|
|
||||||
|
Series page usage:
|
||||||
|
|
||||||
|
- Open a series page and use `Organize Story Order With AI`
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,15 @@ const Scanner = require('../scanner/Scanner')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Watcher = require('../Watcher')
|
const Watcher = require('../Watcher')
|
||||||
const RssFeedManager = require('../managers/RssFeedManager')
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
const OpenAI = require('../providers/OpenAI')
|
||||||
|
|
||||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||||
const authorFilters = require('../utils/queries/authorFilters')
|
const authorFilters = require('../utils/queries/authorFilters')
|
||||||
const zipHelpers = require('../utils/zipHelpers')
|
const zipHelpers = require('../utils/zipHelpers')
|
||||||
|
|
||||||
|
const openAI = new OpenAI()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RequestUserObject
|
* @typedef RequestUserObject
|
||||||
* @property {import('../models/User')} user
|
* @property {import('../models/User')} user
|
||||||
|
|
@ -1463,6 +1466,422 @@ 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()]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeBookTitleForAIDedupe(title) {
|
||||||
|
if (!title || typeof title !== 'string') return null
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\([^)]*\)/g, ' ')
|
||||||
|
.replace(/\[[^\]]*]/g, ' ')
|
||||||
|
.replace(/\b(unabridged|abridged|audiobook|audio book)\b/g, ' ')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
groupLibraryBooksForAIDedupe(libraryItems) {
|
||||||
|
const parent = new Map()
|
||||||
|
const find = (id) => {
|
||||||
|
if (parent.get(id) !== id) {
|
||||||
|
parent.set(id, find(parent.get(id)))
|
||||||
|
}
|
||||||
|
return parent.get(id)
|
||||||
|
}
|
||||||
|
const union = (a, b) => {
|
||||||
|
const rootA = find(a)
|
||||||
|
const rootB = find(b)
|
||||||
|
if (rootA !== rootB) parent.set(rootB, rootA)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItems.forEach((libraryItem) => parent.set(libraryItem.id, libraryItem.id))
|
||||||
|
|
||||||
|
const candidateMaps = [new Map(), new Map(), new Map()]
|
||||||
|
libraryItems.forEach((libraryItem) => {
|
||||||
|
const metadata = libraryItem.media.oldMetadataToJSON()
|
||||||
|
const primaryAuthor = metadata.authors?.[0]?.name?.trim().toLowerCase() || null
|
||||||
|
const normalizedTitle = LibraryController.prototype.normalizeBookTitleForAIDedupe.call(this, metadata.title || '')
|
||||||
|
const isbn = metadata.isbn?.replace(/[-\s]/g, '').toLowerCase() || null
|
||||||
|
const asin = metadata.asin?.trim().toLowerCase() || null
|
||||||
|
|
||||||
|
const candidateKeys = []
|
||||||
|
if (primaryAuthor && normalizedTitle) candidateKeys.push([candidateMaps[0], `${primaryAuthor}::${normalizedTitle}`])
|
||||||
|
if (isbn) candidateKeys.push([candidateMaps[1], `isbn::${isbn}`])
|
||||||
|
if (asin) candidateKeys.push([candidateMaps[2], `asin::${asin}`])
|
||||||
|
|
||||||
|
candidateKeys.forEach(([candidateMap, key]) => {
|
||||||
|
if (!candidateMap.has(key)) candidateMap.set(key, [])
|
||||||
|
candidateMap.get(key).push(libraryItem)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
candidateMaps.forEach((candidateMap) => {
|
||||||
|
candidateMap.forEach((groupItems) => {
|
||||||
|
if (groupItems.length < 2) return
|
||||||
|
for (let i = 1; i < groupItems.length; i++) {
|
||||||
|
union(groupItems[0].id, groupItems[i].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const grouped = new Map()
|
||||||
|
libraryItems.forEach((libraryItem) => {
|
||||||
|
const root = find(libraryItem.id)
|
||||||
|
if (!grouped.has(root)) grouped.set(root, [])
|
||||||
|
grouped.get(root).push(libraryItem)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...grouped.values()]
|
||||||
|
.filter((groupItems) => groupItems.length > 1)
|
||||||
|
.map((groupItems) => ({
|
||||||
|
label: groupItems.map((libraryItem) => libraryItem.media.title).join(' | '),
|
||||||
|
libraryItems: groupItems.sort((a, b) => a.media.title.localeCompare(b.media.title))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeleteDependenciesForLibraryItem(libraryItem) {
|
||||||
|
const mediaItemIds = []
|
||||||
|
const authorIds = []
|
||||||
|
const seriesIds = []
|
||||||
|
|
||||||
|
mediaItemIds.push(libraryItem.media.id)
|
||||||
|
if (libraryItem.media.authors?.length) {
|
||||||
|
authorIds.push(...libraryItem.media.authors.map((author) => author.id))
|
||||||
|
}
|
||||||
|
if (libraryItem.media.series?.length) {
|
||||||
|
seriesIds.push(...libraryItem.media.series.map((series) => series.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaItemIds,
|
||||||
|
authorIds,
|
||||||
|
seriesIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/libraries/:id/dedupe-books-with-ai
|
||||||
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
|
* @param {LibraryControllerRequest} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async dedupeBooksWithAI(req, res) {
|
||||||
|
if (!req.user.canUpdate) {
|
||||||
|
Logger.warn(`[LibraryController] User "${req.user.username}" attempted AI dedupe without update permissions`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
if (req.library.mediaType !== 'book') {
|
||||||
|
return res.status(400).send('AI book dedupe is only available for book libraries')
|
||||||
|
}
|
||||||
|
if (!openAI.isConfigured) {
|
||||||
|
return res.status(400).send('OpenAI is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hardDelete = req.query.hard !== '0'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const libraryItems = await LibraryController.prototype.getLibraryBooksForAISeriesDetection.call(this, req.library.id)
|
||||||
|
const candidateGroups = LibraryController.prototype.groupLibraryBooksForAIDedupe.call(this, libraryItems)
|
||||||
|
|
||||||
|
let groupsProcessed = 0
|
||||||
|
let duplicatesRemoved = 0
|
||||||
|
const removedIds = new Set()
|
||||||
|
const authorIdsToCheck = new Set()
|
||||||
|
const seriesIdsToCheck = new Set()
|
||||||
|
|
||||||
|
for (const candidateGroup of candidateGroups) {
|
||||||
|
const activeLibraryItems = candidateGroup.libraryItems.filter((libraryItem) => !removedIds.has(libraryItem.id))
|
||||||
|
if (activeLibraryItems.length < 2) continue
|
||||||
|
|
||||||
|
Logger.info(`[LibraryController] AI dedupe evaluating candidate group "${candidateGroup.label}" with ${activeLibraryItems.length} books`)
|
||||||
|
const decisions = await openAI.detectDuplicateBooks(activeLibraryItems)
|
||||||
|
groupsProcessed++
|
||||||
|
|
||||||
|
for (const decision of decisions) {
|
||||||
|
for (const duplicateId of decision.duplicateIds) {
|
||||||
|
if (removedIds.has(duplicateId) || duplicateId === decision.keepId) continue
|
||||||
|
const duplicateItem = activeLibraryItems.find((libraryItem) => libraryItem.id === duplicateId)
|
||||||
|
if (!duplicateItem) continue
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
`[LibraryController] AI dedupe removing duplicate "${duplicateItem.media.title}" (${duplicateItem.id}) keeping "${decision.keepId}" reason="${decision.reason || ''}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteDependencies = LibraryController.prototype.getDeleteDependenciesForLibraryItem.call(this, duplicateItem)
|
||||||
|
await this.handleDeleteLibraryItem(duplicateItem.id, deleteDependencies.mediaItemIds, req.library.id)
|
||||||
|
if (hardDelete) {
|
||||||
|
await fs.remove(duplicateItem.path).catch((error) => {
|
||||||
|
Logger.error(`[LibraryController] Failed to hard-delete duplicate item path "${duplicateItem.path}"`, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDependencies.authorIds.forEach((authorId) => authorIdsToCheck.add(authorId))
|
||||||
|
deleteDependencies.seriesIds.forEach((seriesId) => seriesIdsToCheck.add(seriesId))
|
||||||
|
removedIds.add(duplicateItem.id)
|
||||||
|
duplicatesRemoved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks([...authorIdsToCheck])
|
||||||
|
await this.checkRemoveEmptySeries([...seriesIdsToCheck])
|
||||||
|
await Database.resetLibraryIssuesFilterData(req.library.id)
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
`[LibraryController] AI book dedupe completed for library "${req.library.name}" - groupsProcessed=${groupsProcessed}, duplicatesRemoved=${duplicatesRemoved}, hardDelete=${hardDelete}`
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
groupsProcessed,
|
||||||
|
duplicatesRemoved,
|
||||||
|
hardDelete
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[LibraryController] Failed AI dedupe for library "${req.library.name}"`, error)
|
||||||
|
res.status(500).send(error.message || 'Failed to dedupe books with AI')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,36 @@ class MiscController {
|
||||||
if (settingsUpdate.allowedOrigins && !Array.isArray(settingsUpdate.allowedOrigins)) {
|
if (settingsUpdate.allowedOrigins && !Array.isArray(settingsUpdate.allowedOrigins)) {
|
||||||
return res.status(400).send('allowedOrigins must be an array')
|
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)
|
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||||
if (madeUpdates) {
|
if (madeUpdates) {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ const { Request, Response, NextFunction } = require('express')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
const OpenAI = require('../providers/OpenAI')
|
||||||
|
|
||||||
const RssFeedManager = require('../managers/RssFeedManager')
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
|
||||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||||
|
|
||||||
|
const openAI = new OpenAI()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RequestUserObject
|
* @typedef RequestUserObject
|
||||||
* @property {import('../models/User')} user
|
* @property {import('../models/User')} user
|
||||||
|
|
@ -86,6 +89,68 @@ class SeriesController {
|
||||||
res.json(req.series.toOldJSON())
|
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
|
* @param {RequestWithUser} req
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const Logger = require('../Logger')
|
||||||
* @property {boolean} audiobooksOnly
|
* @property {boolean} audiobooksOnly
|
||||||
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
|
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
|
||||||
* @property {boolean} onlyShowLaterBooksInContinueSeries Skip showing books that are earlier than the max sequence read
|
* @property {boolean} onlyShowLaterBooksInContinueSeries Skip showing books that are earlier than the max sequence read
|
||||||
|
* @property {boolean} openAIDirectoryGrouping Allow OpenAI to infer library-item grouping from poor directory structures
|
||||||
* @property {string[]} metadataPrecedence
|
* @property {string[]} metadataPrecedence
|
||||||
* @property {number} markAsFinishedTimeRemaining Time remaining in seconds to mark as finished. (defaults to 10s)
|
* @property {number} markAsFinishedTimeRemaining Time remaining in seconds to mark as finished. (defaults to 10s)
|
||||||
* @property {number} markAsFinishedPercentComplete Percent complete to mark as finished (0-100). If this is set it will be used over markAsFinishedTimeRemaining.
|
* @property {number} markAsFinishedPercentComplete Percent complete to mark as finished (0-100). If this is set it will be used over markAsFinishedTimeRemaining.
|
||||||
|
|
@ -74,6 +75,7 @@ class Library extends Model {
|
||||||
epubsAllowScriptedContent: false,
|
epubsAllowScriptedContent: false,
|
||||||
hideSingleBookSeries: false,
|
hideSingleBookSeries: false,
|
||||||
onlyShowLaterBooksInContinueSeries: false,
|
onlyShowLaterBooksInContinueSeries: false,
|
||||||
|
openAIDirectoryGrouping: false,
|
||||||
metadataPrecedence: this.defaultMetadataPrecedence,
|
metadataPrecedence: this.defaultMetadataPrecedence,
|
||||||
markAsFinishedPercentComplete: null,
|
markAsFinishedPercentComplete: null,
|
||||||
markAsFinishedTimeRemaining: 10
|
markAsFinishedTimeRemaining: 10
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ class ServerSettings {
|
||||||
this.authOpenIDAdvancedPermsClaim = ''
|
this.authOpenIDAdvancedPermsClaim = ''
|
||||||
this.authOpenIDSubfolderForRedirectURLs = undefined
|
this.authOpenIDSubfolderForRedirectURLs = undefined
|
||||||
|
|
||||||
|
// OpenAI
|
||||||
|
this.openAIApiKey = null
|
||||||
|
this.openAIBaseURL = 'https://api.openai.com/v1'
|
||||||
|
this.openAIModel = 'gpt-5.4-mini'
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.construct(settings)
|
this.construct(settings)
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +152,9 @@ class ServerSettings {
|
||||||
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
||||||
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||||
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
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)) {
|
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||||
this.authActiveAuthMethods = ['local']
|
this.authActiveAuthMethods = ['local']
|
||||||
|
|
@ -256,7 +264,10 @@ class ServerSettings {
|
||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // 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.authOpenIDMobileRedirectURIs
|
||||||
delete json.authOpenIDGroupClaim
|
delete json.authOpenIDGroupClaim
|
||||||
delete json.authOpenIDAdvancedPermsClaim
|
delete json.authOpenIDAdvancedPermsClaim
|
||||||
|
delete json.openAIApiKey
|
||||||
|
json.openAIConfigured = this.openAIConfigured
|
||||||
|
json.openAIConfigurationSource = this.openAIConfigurationSource
|
||||||
|
json.openAIBaseURL = this.openAIResolvedBaseURL
|
||||||
|
json.openAIModel = this.openAIResolvedModel
|
||||||
return json
|
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() {
|
get supportedAuthMethods() {
|
||||||
return ['local', 'openid']
|
return ['local', 'openid']
|
||||||
}
|
}
|
||||||
|
|
|
||||||
887
server/providers/OpenAI.js
Normal file
887
server/providers/OpenAI.js
Normal file
|
|
@ -0,0 +1,887 @@
|
||||||
|
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 || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeScanMetadataForLog(metadata) {
|
||||||
|
return JSON.stringify({
|
||||||
|
title: metadata.title || null,
|
||||||
|
authors: metadata.authors || [],
|
||||||
|
seriesName: metadata.seriesName || null,
|
||||||
|
sequence: metadata.sequence || null,
|
||||||
|
publishedYear: metadata.publishedYear || null,
|
||||||
|
reason: metadata.reason || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeDirectoryGroupingForLog(grouping) {
|
||||||
|
return JSON.stringify({
|
||||||
|
path: grouping.path,
|
||||||
|
groupId: grouping.groupId,
|
||||||
|
reason: grouping.reason || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
summarizeDuplicateDecisionForLog(decision) {
|
||||||
|
return JSON.stringify({
|
||||||
|
keepId: decision.keepId,
|
||||||
|
duplicateIds: decision.duplicateIds,
|
||||||
|
reason: decision.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
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeOptionalString(value, maxLength = 300) {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!normalized) return null
|
||||||
|
return normalized.slice(0, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeStringArray(value, maxItems = 10, maxLength = 120) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
|
const deduped = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const item of value) {
|
||||||
|
const normalized = this.normalizeOptionalString(item, maxLength)
|
||||||
|
if (!normalized) continue
|
||||||
|
const key = normalized.toLowerCase()
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
deduped.push(normalized)
|
||||||
|
if (deduped.length >= maxItems) break
|
||||||
|
}
|
||||||
|
return deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePublishedYear(value) {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) value = String(value)
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const normalized = value.trim()
|
||||||
|
if (!/^\d{4}$/.test(normalized)) return null
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeIsbn(value) {
|
||||||
|
const normalized = this.normalizeOptionalString(value, 20)
|
||||||
|
if (!normalized) return null
|
||||||
|
const compact = normalized.replace(/[-\s]/g, '')
|
||||||
|
if (!/^(?:\d{10}|\d{13}|[0-9X]{10})$/i.test(compact)) return null
|
||||||
|
return compact
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAsin(value) {
|
||||||
|
const normalized = this.normalizeOptionalString(value, 10)
|
||||||
|
if (!normalized) return null
|
||||||
|
return /^[A-Z0-9]{10}$/i.test(normalized) ? normalized.toUpperCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
validateScanMetadataPayload(payload) {
|
||||||
|
const book = payload?.book && typeof payload.book === 'object' ? payload.book : payload
|
||||||
|
if (!book || typeof book !== 'object' || Array.isArray(book)) {
|
||||||
|
throw new Error('OpenAI returned invalid scan metadata payload')
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesName = this.normalizeSeriesName(book.seriesName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: this.normalizeOptionalString(book.title),
|
||||||
|
subtitle: this.normalizeOptionalString(book.subtitle),
|
||||||
|
publishedYear: this.normalizePublishedYear(book.publishedYear),
|
||||||
|
publisher: this.normalizeOptionalString(book.publisher),
|
||||||
|
isbn: this.normalizeIsbn(book.isbn),
|
||||||
|
asin: this.normalizeAsin(book.asin),
|
||||||
|
language: this.normalizeOptionalString(book.language, 40),
|
||||||
|
authors: this.normalizeStringArray(book.authors),
|
||||||
|
narrators: this.normalizeStringArray(book.narrators),
|
||||||
|
seriesName,
|
||||||
|
sequence: seriesName ? this.normalizeSequence(book.sequence) : null,
|
||||||
|
reason: this.normalizeOptionalString(book.reason, 600) || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateDirectoryGroupingPayload(payload, mediaFiles) {
|
||||||
|
const resultFiles = payload?.files
|
||||||
|
if (!Array.isArray(resultFiles)) {
|
||||||
|
throw new Error('OpenAI returned invalid directory-grouping payload')
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedPaths = new Set(mediaFiles.map((file) => file.path))
|
||||||
|
const resultByPath = new Map()
|
||||||
|
|
||||||
|
resultFiles.forEach((file) => {
|
||||||
|
if (!expectedPaths.has(file?.path)) {
|
||||||
|
Logger.warn(`[OpenAI] Ignoring unknown media path "${file?.path}" in directory-grouping response`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (resultByPath.has(file.path)) {
|
||||||
|
Logger.warn(`[OpenAI] Ignoring duplicate media path "${file.path}" in directory-grouping response`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resultByPath.set(file.path, file)
|
||||||
|
})
|
||||||
|
|
||||||
|
return mediaFiles.map((file) => {
|
||||||
|
const result = resultByPath.get(file.path)
|
||||||
|
const groupId = this.normalizeOptionalString(result?.groupId, 120) || file.path
|
||||||
|
const reason = this.normalizeOptionalString(result?.reason, 600) || (result ? '' : 'OpenAI omitted this media file; kept it as its own item')
|
||||||
|
if (!result) {
|
||||||
|
Logger.warn(`[OpenAI] Missing directory-grouping result for media path "${file.path}" - keeping it separate`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
groupId,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
validateDuplicateBooksPayload(payload, books) {
|
||||||
|
const resultGroups = Array.isArray(payload?.groups) ? payload.groups : []
|
||||||
|
const expectedIds = new Set(books.map((book) => book.id))
|
||||||
|
const consumedIds = new Set()
|
||||||
|
const validated = []
|
||||||
|
|
||||||
|
resultGroups.forEach((group) => {
|
||||||
|
const keepId = this.normalizeOptionalString(group?.keepId, 120)
|
||||||
|
if (!keepId || !expectedIds.has(keepId)) {
|
||||||
|
Logger.warn(`[OpenAI] Ignoring duplicate-books group with invalid keepId "${group?.keepId}"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (consumedIds.has(keepId)) {
|
||||||
|
Logger.warn(`[OpenAI] Ignoring duplicate-books group because keepId "${keepId}" was already used`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateIds = Array.isArray(group?.duplicateIds)
|
||||||
|
? group.duplicateIds
|
||||||
|
.map((duplicateId) => this.normalizeOptionalString(duplicateId, 120))
|
||||||
|
.filter((duplicateId) => duplicateId && expectedIds.has(duplicateId) && duplicateId !== keepId)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const dedupedDuplicateIds = []
|
||||||
|
const seenDuplicateIds = new Set()
|
||||||
|
duplicateIds.forEach((duplicateId) => {
|
||||||
|
if (seenDuplicateIds.has(duplicateId) || consumedIds.has(duplicateId)) return
|
||||||
|
seenDuplicateIds.add(duplicateId)
|
||||||
|
dedupedDuplicateIds.push(duplicateId)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!dedupedDuplicateIds.length) return
|
||||||
|
|
||||||
|
consumedIds.add(keepId)
|
||||||
|
dedupedDuplicateIds.forEach((duplicateId) => consumedIds.add(duplicateId))
|
||||||
|
validated.push({
|
||||||
|
keepId,
|
||||||
|
duplicateIds: dedupedDuplicateIds,
|
||||||
|
reason: this.normalizeOptionalString(group?.reason, 600) || ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return validated
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeDetectionResultBooks(resultBooks, books) {
|
||||||
|
if (!Array.isArray(resultBooks)) {
|
||||||
|
throw new Error('OpenAI returned an invalid books payload')
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedIds = new Set(books.map((book) => book.id))
|
||||||
|
const resultBooksById = new Map()
|
||||||
|
|
||||||
|
resultBooks.forEach((book) => {
|
||||||
|
if (!expectedIds.has(book?.id)) {
|
||||||
|
Logger.warn(`[OpenAI] Ignoring unknown book id "${book?.id}" in series-detection response`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (resultBooksById.has(book.id)) {
|
||||||
|
Logger.warn(`[OpenAI] Ignoring duplicate book id "${book.id}" in series-detection response`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resultBooksById.set(book.id, book)
|
||||||
|
})
|
||||||
|
|
||||||
|
return books.map((book) => {
|
||||||
|
if (resultBooksById.has(book.id)) {
|
||||||
|
return resultBooksById.get(book.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.warn(`[OpenAI] Missing series-detection result for book "${book.id}" - skipping assignment`)
|
||||||
|
return {
|
||||||
|
id: book.id,
|
||||||
|
seriesName: null,
|
||||||
|
sequence: null,
|
||||||
|
reason: 'Skipped because OpenAI omitted this book from the response'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = this.normalizeDetectionResultBooks(payload?.books, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
async inferBookMetadataFromScan(libraryItemData, audioFiles = [], ebookFileScanData = null) {
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
throw new Error('OpenAI API key is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderContext = this.getFolderContext(libraryItemData)
|
||||||
|
const audioFileCandidates = (audioFiles || []).slice(0, 25).map((audioFile) => ({
|
||||||
|
relPath: this.normalizePathForPrompt(audioFile.metadata?.relPath),
|
||||||
|
filename: audioFile.metadata?.filename || null,
|
||||||
|
duration: audioFile.duration || null,
|
||||||
|
trackNumber: audioFile.trackNumFromMeta || audioFile.metaTags?.trackNumber || null,
|
||||||
|
discNumber: audioFile.discNumFromMeta || audioFile.metaTags?.discNumber || null,
|
||||||
|
metaTags: {
|
||||||
|
tagTitle: audioFile.metaTags?.tagTitle || null,
|
||||||
|
tagAlbum: audioFile.metaTags?.tagAlbum || null,
|
||||||
|
tagArtist: audioFile.metaTags?.tagArtist || null,
|
||||||
|
tagAlbumArtist: audioFile.metaTags?.tagAlbumArtist || null,
|
||||||
|
tagSeries: audioFile.metaTags?.tagSeries || null,
|
||||||
|
tagSeriesPart: audioFile.metaTags?.tagSeriesPart || null,
|
||||||
|
tagSubtitle: audioFile.metaTags?.tagSubtitle || null,
|
||||||
|
tagDate: audioFile.metaTags?.tagDate || null,
|
||||||
|
tagASIN: audioFile.metaTags?.tagASIN || null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ebookMetadata = ebookFileScanData?.metadata
|
||||||
|
? {
|
||||||
|
title: ebookFileScanData.metadata.title || null,
|
||||||
|
subtitle: ebookFileScanData.metadata.subtitle || null,
|
||||||
|
authors: ebookFileScanData.metadata.authors || [],
|
||||||
|
narrators: ebookFileScanData.metadata.narrators || [],
|
||||||
|
series: ebookFileScanData.metadata.series || [],
|
||||||
|
publishedYear: ebookFileScanData.metadata.publishedYear || null,
|
||||||
|
isbn: ebookFileScanData.metadata.isbn || null,
|
||||||
|
asin: ebookFileScanData.metadata.asin || null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const currentPathMetadata = {
|
||||||
|
title: libraryItemData.mediaMetadata?.title || null,
|
||||||
|
subtitle: libraryItemData.mediaMetadata?.subtitle || null,
|
||||||
|
authors: libraryItemData.mediaMetadata?.authors || [],
|
||||||
|
narrators: libraryItemData.mediaMetadata?.narrators || [],
|
||||||
|
seriesName: libraryItemData.mediaMetadata?.seriesName || null,
|
||||||
|
sequence: libraryItemData.mediaMetadata?.seriesSequence || null,
|
||||||
|
publishedYear: libraryItemData.mediaMetadata?.publishedYear || null
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`[OpenAI] Inferring scan metadata for "${libraryItemData.relPath}"`)
|
||||||
|
|
||||||
|
const prompt = `You infer audiobook metadata from weak or messy directory structures.
|
||||||
|
|
||||||
|
Return only valid JSON in this shape:
|
||||||
|
{
|
||||||
|
"book": {
|
||||||
|
"title": "Book title or null",
|
||||||
|
"subtitle": "Subtitle or null",
|
||||||
|
"authors": ["Author Name"],
|
||||||
|
"narrators": ["Narrator Name"],
|
||||||
|
"seriesName": "Series name or null",
|
||||||
|
"sequence": "1 or null",
|
||||||
|
"publishedYear": "2004 or null",
|
||||||
|
"publisher": "Publisher or null",
|
||||||
|
"isbn": "ISBN or null",
|
||||||
|
"asin": "ASIN or null",
|
||||||
|
"language": "Language or null",
|
||||||
|
"reason": "brief reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Infer metadata from full path, relative path, folder names, filenames, and any provided tag metadata.
|
||||||
|
- Prefer title/author/series evidence that is explicit in filenames or tags.
|
||||||
|
- Use null when uncertain.
|
||||||
|
- If a series is provided, sequence may be null when it cannot be inferred confidently.
|
||||||
|
- Do not invent authors or series when there is weak evidence.
|
||||||
|
- Respond with one book object only.
|
||||||
|
|
||||||
|
Current path-derived metadata:
|
||||||
|
${JSON.stringify(currentPathMetadata, null, 2)}
|
||||||
|
|
||||||
|
Folder context:
|
||||||
|
${JSON.stringify(folderContext, null, 2)}
|
||||||
|
|
||||||
|
Audio files:
|
||||||
|
${JSON.stringify(audioFileCandidates, null, 2)}
|
||||||
|
|
||||||
|
Ebook metadata:
|
||||||
|
${JSON.stringify(ebookMetadata, null, 2)}`
|
||||||
|
|
||||||
|
const payload = await this.createResponse(prompt)
|
||||||
|
const validated = this.validateScanMetadataPayload(payload)
|
||||||
|
Logger.info(`[OpenAI] Scan-metadata result for "${libraryItemData.relPath}" ${this.summarizeScanMetadataForLog(validated)}`)
|
||||||
|
return validated
|
||||||
|
}
|
||||||
|
|
||||||
|
async inferDirectoryGroupingFromPaths(containerPath, mediaFiles) {
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
throw new Error('OpenAI API key is not configured')
|
||||||
|
}
|
||||||
|
if (!Array.isArray(mediaFiles) || !mediaFiles.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`[OpenAI] Inferring directory grouping for "${containerPath}" with ${mediaFiles.length} media files`)
|
||||||
|
mediaFiles.forEach((file) => {
|
||||||
|
Logger.info(`[OpenAI] Directory-grouping candidate ${JSON.stringify(file)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const prompt = `You infer logical audiobook item grouping from messy filesystem paths.
|
||||||
|
|
||||||
|
Return only valid JSON in this shape:
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "relative/path/to/media-file.m4b",
|
||||||
|
"groupId": "short-group-label",
|
||||||
|
"reason": "brief reason"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Include every provided media file exactly once.
|
||||||
|
- Files that belong to the same logical audiobook item must share the same groupId.
|
||||||
|
- Files for different books must use different groupIds even if they are in the same series container.
|
||||||
|
- Use path, filename, parent directories, and current grouping hints as evidence.
|
||||||
|
- Prefer preserving existing grouping when it already looks reasonable.
|
||||||
|
- Do not merge different titled books just because they share a series or author folder.
|
||||||
|
- groupId only needs to be stable within this one response.
|
||||||
|
|
||||||
|
Container path:
|
||||||
|
${JSON.stringify(containerPath)}
|
||||||
|
|
||||||
|
Media files:
|
||||||
|
${JSON.stringify(mediaFiles, null, 2)}`
|
||||||
|
|
||||||
|
const payload = await this.createResponse(prompt)
|
||||||
|
const validated = this.validateDirectoryGroupingPayload(payload, mediaFiles)
|
||||||
|
validated.forEach((grouping) => {
|
||||||
|
Logger.info(`[OpenAI] Directory-grouping result ${this.summarizeDirectoryGroupingForLog(grouping)}`)
|
||||||
|
})
|
||||||
|
return validated
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectDuplicateBooks(libraryItems) {
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
throw new Error('OpenAI API key is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const books = libraryItems.map((libraryItem) => {
|
||||||
|
const metadata = libraryItem.media.oldMetadataToJSON()
|
||||||
|
const folderContext = this.getFolderContext(libraryItem)
|
||||||
|
const metadataCompletenessScore = [
|
||||||
|
metadata.title,
|
||||||
|
metadata.subtitle,
|
||||||
|
metadata.description,
|
||||||
|
metadata.isbn,
|
||||||
|
metadata.asin,
|
||||||
|
metadata.publisher,
|
||||||
|
metadata.language,
|
||||||
|
metadata.publishedYear,
|
||||||
|
metadata.authors?.length ? 'authors' : null,
|
||||||
|
metadata.series?.length ? 'series' : null,
|
||||||
|
metadata.narrators?.length ? 'narrators' : null,
|
||||||
|
libraryItem.media.coverPath ? 'cover' : null
|
||||||
|
].filter(Boolean).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: libraryItem.id,
|
||||||
|
title: metadata.title || null,
|
||||||
|
subtitle: metadata.subtitle || null,
|
||||||
|
authors: (metadata.authors || []).map((author) => author.name),
|
||||||
|
narrators: metadata.narrators || [],
|
||||||
|
series: (metadata.series || []).map((series) => ({ name: series.name, sequence: series.sequence || null })),
|
||||||
|
publishedYear: metadata.publishedYear || null,
|
||||||
|
description: this.cleanDescription(metadata.description),
|
||||||
|
language: metadata.language || null,
|
||||||
|
abridged: !!metadata.abridged,
|
||||||
|
explicit: !!metadata.explicit,
|
||||||
|
isbn: metadata.isbn || null,
|
||||||
|
asin: metadata.asin || null,
|
||||||
|
duration: libraryItem.media.duration || null,
|
||||||
|
size: libraryItem.media.size || libraryItem.size || null,
|
||||||
|
numAudioFiles: libraryItem.media.audioFiles?.length || 0,
|
||||||
|
numChapters: libraryItem.media.chapters?.length || 0,
|
||||||
|
hasCover: !!libraryItem.media.coverPath,
|
||||||
|
ebookFormat: libraryItem.media.ebookFile?.ebookFormat || null,
|
||||||
|
isFile: !!libraryItem.isFile,
|
||||||
|
fullPath: folderContext.fullPath,
|
||||||
|
relPath: folderContext.relPath,
|
||||||
|
metadataCompletenessScore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Logger.info(`[OpenAI] Evaluating duplicate books for ${books.length} candidates`)
|
||||||
|
books.forEach((book) => {
|
||||||
|
Logger.info(`[OpenAI] Duplicate-books candidate ${JSON.stringify(book)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const prompt = `You identify duplicate audiobook library items that represent the same underlying book/work and choose which copy to keep.
|
||||||
|
|
||||||
|
Return only valid JSON in this shape:
|
||||||
|
{
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"keepId": "library-item-id-to-keep",
|
||||||
|
"duplicateIds": ["library-item-id-to-remove"],
|
||||||
|
"reason": "brief reason"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only mark items as duplicates if they are clearly the same book/work.
|
||||||
|
- Books in the same series are not duplicates unless they are the same title/work.
|
||||||
|
- Different abridged vs unabridged editions, different languages, dramatizations, companions, or supplemental books are not duplicates unless the evidence strongly indicates they are just duplicate copies.
|
||||||
|
- Prefer keeping the copy with richer metadata, cleaner path naming, cover art, more complete file data, and generally better organization.
|
||||||
|
- Do not include a group if no duplicates should be removed.
|
||||||
|
- Do not include the same id in more than one group.
|
||||||
|
|
||||||
|
Books:
|
||||||
|
${JSON.stringify(books, null, 2)}`
|
||||||
|
|
||||||
|
const payload = await this.createResponse(prompt)
|
||||||
|
const validated = this.validateDuplicateBooksPayload(payload, books)
|
||||||
|
validated.forEach((decision) => {
|
||||||
|
Logger.info(`[OpenAI] Duplicate-books result ${this.summarizeDuplicateDecisionForLog(decision)}`)
|
||||||
|
})
|
||||||
|
return validated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OpenAI
|
||||||
|
|
@ -89,6 +89,8 @@ class ApiRouter {
|
||||||
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
|
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.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/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.post('/libraries/:id/dedupe-books-with-ai', LibraryController.middleware.bind(this), LibraryController.dedupeBooksWithAI.bind(this))
|
||||||
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.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.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
|
||||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||||
|
|
@ -222,6 +224,7 @@ class ApiRouter {
|
||||||
//
|
//
|
||||||
this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
|
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.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
|
// Playback Session Routes
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,15 @@ const LibraryFile = require('../objects/files/LibraryFile')
|
||||||
|
|
||||||
const RssFeedManager = require('../managers/RssFeedManager')
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
const OpenAI = require('../providers/OpenAI')
|
||||||
|
|
||||||
const LibraryScan = require('./LibraryScan')
|
const LibraryScan = require('./LibraryScan')
|
||||||
const OpfFileScanner = require('./OpfFileScanner')
|
const OpfFileScanner = require('./OpfFileScanner')
|
||||||
const NfoFileScanner = require('./NfoFileScanner')
|
const NfoFileScanner = require('./NfoFileScanner')
|
||||||
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||||
|
|
||||||
|
const openAI = new OpenAI()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata for books pulled from files
|
* Metadata for books pulled from files
|
||||||
* @typedef BookMetadataObject
|
* @typedef BookMetadataObject
|
||||||
|
|
@ -761,6 +764,43 @@ class BookScanner {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openAIPathMetadata() {
|
||||||
|
if (!openAI.isConfigured) {
|
||||||
|
this.libraryScan.addLog(LogLevel.DEBUG, `Skipping OpenAI path metadata for "${this.libraryItemData.relPath}" because OpenAI is not configured`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredMetadata = await openAI
|
||||||
|
.inferBookMetadataFromScan(this.libraryItemData, this.audioFiles, this.ebookFileScanData)
|
||||||
|
.catch((error) => {
|
||||||
|
this.libraryScan.addLog(LogLevel.WARN, `OpenAI path metadata failed for "${this.libraryItemData.relPath}": ${error.message}`)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!inferredMetadata) return
|
||||||
|
|
||||||
|
const directKeys = ['title', 'subtitle', 'publishedYear', 'publisher', 'isbn', 'asin', 'language']
|
||||||
|
directKeys.forEach((key) => {
|
||||||
|
if (inferredMetadata[key]) {
|
||||||
|
this.bookMetadata[key] = inferredMetadata[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (inferredMetadata.authors.length) {
|
||||||
|
this.bookMetadata.authors = inferredMetadata.authors
|
||||||
|
}
|
||||||
|
if (inferredMetadata.narrators.length) {
|
||||||
|
this.bookMetadata.narrators = inferredMetadata.narrators
|
||||||
|
}
|
||||||
|
if (inferredMetadata.seriesName) {
|
||||||
|
this.bookMetadata.series = [
|
||||||
|
{
|
||||||
|
name: inferredMetadata.seriesName,
|
||||||
|
sequence: inferredMetadata.sequence || null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata from .nfo file
|
* Metadata from .nfo file
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const Database = require('../Database')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const fileUtils = require('../utils/fileUtils')
|
const fileUtils = require('../utils/fileUtils')
|
||||||
const scanUtils = require('../utils/scandir')
|
const scanUtils = require('../utils/scandir')
|
||||||
|
const globals = require('../utils/globals')
|
||||||
const { LogLevel, ScanResult } = require('../utils/constants')
|
const { LogLevel, ScanResult } = require('../utils/constants')
|
||||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||||
const TaskManager = require('../managers/TaskManager')
|
const TaskManager = require('../managers/TaskManager')
|
||||||
|
|
@ -14,6 +15,10 @@ const LibraryItemScanner = require('./LibraryItemScanner')
|
||||||
const LibraryScan = require('./LibraryScan')
|
const LibraryScan = require('./LibraryScan')
|
||||||
const LibraryItemScanData = require('./LibraryItemScanData')
|
const LibraryItemScanData = require('./LibraryItemScanData')
|
||||||
const Task = require('../objects/Task')
|
const Task = require('../objects/Task')
|
||||||
|
const OpenAI = require('../providers/OpenAI')
|
||||||
|
|
||||||
|
const openAI = new OpenAI()
|
||||||
|
const DISC_DIR_REGEX = /^(cd|dis[ck])\s*\d{1,3}$/i
|
||||||
|
|
||||||
class LibraryScanner {
|
class LibraryScanner {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -309,7 +314,10 @@ class LibraryScanner {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileItems = await fileUtils.recurseFiles(folderPath)
|
const fileItems = await fileUtils.recurseFiles(folderPath)
|
||||||
const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, fileItems, library.settings.audiobooksOnly)
|
let libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, fileItems, library.settings.audiobooksOnly)
|
||||||
|
if (library.mediaType === 'book' && library.settings.openAIDirectoryGrouping && openAI.isConfigured) {
|
||||||
|
libraryItemGrouping = await this.applyOpenAIDirectoryGrouping(folderPath, fileItems, libraryItemGrouping, library.settings.audiobooksOnly)
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(libraryItemGrouping).length) {
|
if (!Object.keys(libraryItemGrouping).length) {
|
||||||
Logger.error(`Root path has no media folders: ${folderPath}`)
|
Logger.error(`Root path has no media folders: ${folderPath}`)
|
||||||
|
|
@ -321,8 +329,12 @@ class LibraryScanner {
|
||||||
let isFile = false // item is not in a folder
|
let isFile = false // item is not in a folder
|
||||||
let libraryItemData = null
|
let libraryItemData = null
|
||||||
let fileObjs = []
|
let fileObjs = []
|
||||||
if (libraryItemPath === libraryItemGrouping[libraryItemPath]) {
|
const groupedFiles = libraryItemGrouping[libraryItemPath]
|
||||||
// Media file in root only get title
|
const isSingleFileGroup =
|
||||||
|
libraryItemPath === groupedFiles || (Array.isArray(groupedFiles) && groupedFiles.includes(libraryItemPath))
|
||||||
|
|
||||||
|
if (isSingleFileGroup) {
|
||||||
|
// Media file item may exist in the library root or inside a poorly-structured parent folder.
|
||||||
libraryItemData = {
|
libraryItemData = {
|
||||||
mediaMetadata: {
|
mediaMetadata: {
|
||||||
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
||||||
|
|
@ -330,11 +342,11 @@ class LibraryScanner {
|
||||||
path: Path.posix.join(folderPath, libraryItemPath),
|
path: Path.posix.join(folderPath, libraryItemPath),
|
||||||
relPath: libraryItemPath
|
relPath: libraryItemPath
|
||||||
}
|
}
|
||||||
fileObjs = await scanUtils.buildLibraryFile(folderPath, [libraryItemPath])
|
fileObjs = await scanUtils.buildLibraryFile(folderPath, Array.isArray(groupedFiles) ? groupedFiles : [libraryItemPath])
|
||||||
isFile = true
|
isFile = true
|
||||||
} else {
|
} else {
|
||||||
libraryItemData = scanUtils.getDataFromMediaDir(library.mediaType, folderPath, libraryItemPath)
|
libraryItemData = scanUtils.getDataFromMediaDir(library.mediaType, folderPath, libraryItemPath)
|
||||||
fileObjs = await scanUtils.buildLibraryFile(libraryItemData.path, libraryItemGrouping[libraryItemPath])
|
fileObjs = await scanUtils.buildLibraryFile(libraryItemData.path, groupedFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItemFolderStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
|
const libraryItemFolderStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
|
||||||
|
|
@ -364,6 +376,201 @@ class LibraryScanner {
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expandGroupingFiles(groupPath, groupedFiles) {
|
||||||
|
if (groupPath === groupedFiles) return [groupPath]
|
||||||
|
return groupedFiles.map((file) => {
|
||||||
|
if (file === groupPath || file.startsWith(groupPath + '/')) return file
|
||||||
|
return Path.posix.join(groupPath, file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenAIDirectoryGroupingCandidates(fileItems, mediaType, audiobooksOnly, libraryItemGrouping) {
|
||||||
|
if (mediaType !== 'book') return []
|
||||||
|
|
||||||
|
const mediaFileItems = fileItems.filter((item) => isMediaFilePath(mediaType, item.path, audiobooksOnly))
|
||||||
|
const candidatesByContainer = new Map()
|
||||||
|
|
||||||
|
mediaFileItems.forEach((item) => {
|
||||||
|
const topLevelDir = item.path.split('/').filter(Boolean)[0]
|
||||||
|
if (!topLevelDir || !item.path.includes('/')) return
|
||||||
|
if (!candidatesByContainer.has(topLevelDir)) {
|
||||||
|
candidatesByContainer.set(topLevelDir, [])
|
||||||
|
}
|
||||||
|
candidatesByContainer.get(topLevelDir).push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...candidatesByContainer.entries()]
|
||||||
|
.map(([containerPath, containerMediaFileItems]) => {
|
||||||
|
const defaultGroupKeys = Object.keys(libraryItemGrouping).filter((groupPath) => groupPath === containerPath || groupPath.startsWith(containerPath + '/'))
|
||||||
|
const hasDirectMediaFileInContainer = containerMediaFileItems.some((item) => Path.posix.dirname(item.path) === containerPath)
|
||||||
|
const hasMixedDefaultFileAndDirectoryGroups =
|
||||||
|
defaultGroupKeys.some((groupPath) => Path.posix.extname(groupPath)) && defaultGroupKeys.some((groupPath) => !Path.posix.extname(groupPath))
|
||||||
|
const maxRelativeDepth = Math.max(...containerMediaFileItems.map((item) => Path.posix.relative(containerPath, item.path).split('/').filter(Boolean).length))
|
||||||
|
const suspicious = defaultGroupKeys.length <= 1 || hasDirectMediaFileInContainer || hasMixedDefaultFileAndDirectoryGroups || maxRelativeDepth > 2
|
||||||
|
|
||||||
|
if (!suspicious || containerMediaFileItems.length < 2 || containerMediaFileItems.length > 40) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupingHints = containerMediaFileItems.map((item) => ({
|
||||||
|
path: item.path,
|
||||||
|
filename: item.name,
|
||||||
|
parentDir: item.reldirpath || '',
|
||||||
|
folderHierarchy: item.path.split('/').slice(0, -1).filter(Boolean),
|
||||||
|
currentGroup: defaultGroupKeys.find((groupPath) => this.expandGroupingFiles(groupPath, libraryItemGrouping[groupPath]).includes(item.path)) || null
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerPath,
|
||||||
|
groupingHints
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDirectoryGroupingDescriptor(containerPath, mediaPaths) {
|
||||||
|
const sortedMediaPaths = [...mediaPaths].sort((a, b) => a.localeCompare(b))
|
||||||
|
if (sortedMediaPaths.length === 1) {
|
||||||
|
const mediaDir = Path.posix.dirname(sortedMediaPaths[0])
|
||||||
|
if (mediaDir && mediaDir !== '.' && mediaDir !== containerPath) {
|
||||||
|
return {
|
||||||
|
groupPath: mediaDir,
|
||||||
|
isFile: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
groupPath: sortedMediaPaths[0],
|
||||||
|
isFile: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPaths = sortedMediaPaths.map((mediaPath) => mediaPath.split('/'))
|
||||||
|
const commonParts = []
|
||||||
|
for (let i = 0; i < Math.min(...splitPaths.map((parts) => parts.length - 1)); i++) {
|
||||||
|
const segment = splitPaths[0][i]
|
||||||
|
if (splitPaths.every((parts) => parts[i] === segment)) {
|
||||||
|
commonParts.push(segment)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const commonDir = commonParts.join('/')
|
||||||
|
|
||||||
|
if (commonDir) {
|
||||||
|
const canUseFolderGroup = sortedMediaPaths.every((mediaPath) => {
|
||||||
|
const relativeParts = Path.posix.relative(commonDir, mediaPath).split('/').filter(Boolean)
|
||||||
|
return relativeParts.length === 1 || (relativeParts.length === 2 && DISC_DIR_REGEX.test(relativeParts[0]))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (canUseFolderGroup) {
|
||||||
|
return {
|
||||||
|
groupPath: commonDir,
|
||||||
|
isFile: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupPath: sortedMediaPaths[0],
|
||||||
|
isFile: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildLibraryItemGroupingFromOpenAIAssignments(containerPath, fileItems, assignments, mediaType, audiobooksOnly) {
|
||||||
|
const mediaPathsByGroupId = new Map()
|
||||||
|
assignments.forEach((assignment) => {
|
||||||
|
if (!mediaPathsByGroupId.has(assignment.groupId)) {
|
||||||
|
mediaPathsByGroupId.set(assignment.groupId, [])
|
||||||
|
}
|
||||||
|
mediaPathsByGroupId.get(assignment.groupId).push(assignment.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupRecords = [...mediaPathsByGroupId.entries()].map(([groupId, mediaPaths]) => {
|
||||||
|
const descriptor = this.getDirectoryGroupingDescriptor(containerPath, mediaPaths)
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
descriptor,
|
||||||
|
mediaPaths: [...mediaPaths].sort((a, b) => a.localeCompare(b)),
|
||||||
|
files: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
groupRecords.forEach((groupRecord) => {
|
||||||
|
if (groupRecord.descriptor.isFile) {
|
||||||
|
groupRecord.files.push(...groupRecord.mediaPaths)
|
||||||
|
} else {
|
||||||
|
groupRecord.files.push(...groupRecord.mediaPaths.map((mediaPath) => Path.posix.relative(groupRecord.descriptor.groupPath, mediaPath)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const nonMediaItems = fileItems.filter((item) => !isMediaFilePath(mediaType, item.path, audiobooksOnly))
|
||||||
|
nonMediaItems.forEach((item) => {
|
||||||
|
const itemStem = Path.basename(item.name, item.extension)
|
||||||
|
|
||||||
|
let matchingGroup = null
|
||||||
|
const basenameMatches = groupRecords.filter((groupRecord) =>
|
||||||
|
groupRecord.mediaPaths.some((mediaPath) => Path.posix.dirname(mediaPath) === item.reldirpath && Path.basename(mediaPath, Path.extname(mediaPath)) === itemStem)
|
||||||
|
)
|
||||||
|
if (basenameMatches.length === 1) {
|
||||||
|
matchingGroup = basenameMatches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchingGroup) {
|
||||||
|
const directoryMatches = groupRecords.filter((groupRecord) => {
|
||||||
|
if (!groupRecord.descriptor.isFile) {
|
||||||
|
return item.path.startsWith(groupRecord.descriptor.groupPath + '/')
|
||||||
|
}
|
||||||
|
return Path.posix.dirname(groupRecord.descriptor.groupPath) === item.reldirpath
|
||||||
|
})
|
||||||
|
if (directoryMatches.length === 1) {
|
||||||
|
matchingGroup = directoryMatches[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchingGroup) return
|
||||||
|
|
||||||
|
const fileEntry = matchingGroup.descriptor.isFile ? item.path : Path.posix.relative(matchingGroup.descriptor.groupPath, item.path)
|
||||||
|
if (!matchingGroup.files.includes(fileEntry)) {
|
||||||
|
matchingGroup.files.push(fileEntry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return groupRecords.reduce((acc, groupRecord) => {
|
||||||
|
acc[groupRecord.descriptor.groupPath] = [...new Set(groupRecord.files)]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyOpenAIDirectoryGrouping(folderPath, fileItems, libraryItemGrouping, audiobooksOnly) {
|
||||||
|
const candidates = this.getOpenAIDirectoryGroupingCandidates(fileItems, 'book', audiobooksOnly, libraryItemGrouping)
|
||||||
|
if (!candidates.length) return libraryItemGrouping
|
||||||
|
|
||||||
|
let updatedGrouping = { ...libraryItemGrouping }
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
Logger.info(`[LibraryScanner] Evaluating OpenAI directory grouping for "${candidate.containerPath}" with ${candidate.groupingHints.length} media files`)
|
||||||
|
const containerFileItems = fileItems.filter((item) => item.path === candidate.containerPath || item.path.startsWith(candidate.containerPath + '/'))
|
||||||
|
const assignments = await openAI.inferDirectoryGroupingFromPaths(candidate.containerPath, candidate.groupingHints).catch((error) => {
|
||||||
|
Logger.warn(`[LibraryScanner] OpenAI directory grouping failed for "${candidate.containerPath}": ${error.message}`)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!assignments?.length) continue
|
||||||
|
|
||||||
|
const aiGrouping = this.buildLibraryItemGroupingFromOpenAIAssignments(candidate.containerPath, containerFileItems, assignments, 'book', audiobooksOnly)
|
||||||
|
if (!Object.keys(aiGrouping).length) continue
|
||||||
|
|
||||||
|
updatedGrouping = Object.fromEntries(
|
||||||
|
Object.entries(updatedGrouping).filter(([groupPath]) => !(groupPath === candidate.containerPath || groupPath.startsWith(candidate.containerPath + '/')))
|
||||||
|
)
|
||||||
|
updatedGrouping = {
|
||||||
|
...updatedGrouping,
|
||||||
|
...aiGrouping
|
||||||
|
}
|
||||||
|
Logger.info(`[LibraryScanner] Applied OpenAI directory grouping for "${candidate.containerPath}" -> ${Object.keys(aiGrouping).length} library items`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedGrouping
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan files changed from Watcher
|
* Scan files changed from Watcher
|
||||||
* @param {import('../Watcher').PendingFileUpdate[]} fileUpdates
|
* @param {import('../Watcher').PendingFileUpdate[]} fileUpdates
|
||||||
|
|
@ -646,16 +853,24 @@ function ItemToFileInoMatch(libraryItem1, libraryItem2) {
|
||||||
return libraryItem1.isFile && libraryItem2.libraryFiles.some((lf) => lf.ino === libraryItem1.ino)
|
return libraryItem1.isFile && libraryItem2.libraryFiles.some((lf) => lf.ino === libraryItem1.ino)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMediaFilePath(mediaType, filepath, audiobooksOnly = false) {
|
||||||
|
const ext = Path.extname(filepath).slice(1).toLowerCase()
|
||||||
|
if (!ext) return false
|
||||||
|
if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(ext)
|
||||||
|
if (audiobooksOnly) return globals.SupportedAudioTypes.includes(ext)
|
||||||
|
return globals.SupportedAudioTypes.includes(ext) || globals.SupportedEbookTypes.includes(ext)
|
||||||
|
}
|
||||||
|
|
||||||
function ItemToItemInoMatch(libraryItem1, libraryItem2) {
|
function ItemToItemInoMatch(libraryItem1, libraryItem2) {
|
||||||
return libraryItem1.ino === libraryItem2.ino
|
return libraryItem1.ino === libraryItem2.ino
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAudioFiles(fileUpdateGroup, itemDir) {
|
function hasAudioFiles(fileUpdateGroup, itemDir) {
|
||||||
return isSingleMediaFile(fileUpdateGroup, itemDir) ? scanUtils.checkFilepathIsAudioFile(fileUpdateGroup[itemDir]) : fileUpdateGroup[itemDir].some(scanUtils.checkFilepathIsAudioFile)
|
return isSingleMediaFile(fileUpdateGroup, itemDir) ? scanUtils.checkFilepathIsAudioFile(itemDir) : fileUpdateGroup[itemDir].some(scanUtils.checkFilepathIsAudioFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSingleMediaFile(fileUpdateGroup, itemDir) {
|
function isSingleMediaFile(fileUpdateGroup, itemDir) {
|
||||||
return itemDir === fileUpdateGroup[itemDir]
|
return itemDir === fileUpdateGroup[itemDir] || (Array.isArray(fileUpdateGroup[itemDir]) && fileUpdateGroup[itemDir].includes(itemDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
|
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ function isScannableNonMediaFile(ext) {
|
||||||
return globals.TextFileTypes.includes(extclean) || globals.MetadataFileTypes.includes(extclean) || globals.SupportedImageTypes.includes(extclean)
|
return globals.TextFileTypes.includes(extclean) || globals.MetadataFileTypes.includes(extclean) || globals.SupportedImageTypes.includes(extclean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDiscDirectoryName(name) {
|
||||||
|
return /^(cd|dis[ck])\s*\d{1,3}$/i.test(name || '')
|
||||||
|
}
|
||||||
|
|
||||||
function checkFilepathIsAudioFile(filepath) {
|
function checkFilepathIsAudioFile(filepath) {
|
||||||
const ext = Path.extname(filepath)
|
const ext = Path.extname(filepath)
|
||||||
if (!ext) return false
|
if (!ext) return false
|
||||||
|
|
@ -67,52 +71,76 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly,
|
||||||
|
|
||||||
// Step 3: Group media files (or non-media files if includeNonMediaFiles is true) in library items
|
// Step 3: Group media files (or non-media files if includeNonMediaFiles is true) in library items
|
||||||
const libraryItemGroup = {}
|
const libraryItemGroup = {}
|
||||||
|
const directMediaFileCountByDir = {}
|
||||||
|
const hasNestedNonDiscMediaByDir = {}
|
||||||
|
|
||||||
|
mediaFileItems.forEach((item) => {
|
||||||
|
const dirPath = item.reldirpath || ''
|
||||||
|
directMediaFileCountByDir[dirPath] = (directMediaFileCountByDir[dirPath] || 0) + 1
|
||||||
|
|
||||||
|
const dirParts = dirPath.split('/').filter(Boolean)
|
||||||
|
for (let i = 0; i < dirParts.length - 1; i++) {
|
||||||
|
const ancestorPath = dirParts.slice(0, i + 1).join('/')
|
||||||
|
const nextSegment = dirParts[i + 1]
|
||||||
|
if (!isDiscDirectoryName(nextSegment)) {
|
||||||
|
hasNestedNonDiscMediaByDir[ancestorPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
mediaFileItems.forEach((item) => {
|
mediaFileItems.forEach((item) => {
|
||||||
const dirparts = item.reldirpath.split('/').filter((p) => !!p)
|
const dirparts = item.reldirpath.split('/').filter((p) => !!p)
|
||||||
const numparts = dirparts.length
|
|
||||||
let _path = ''
|
|
||||||
|
|
||||||
if (!dirparts.length) {
|
if (!dirparts.length) {
|
||||||
// Media file in root
|
// Media file in root
|
||||||
libraryItemGroup[item.name] = item.name
|
libraryItemGroup[item.path] = item.path
|
||||||
} else {
|
} else {
|
||||||
// Iterate over directories in path
|
const dirPath = dirparts.join('/')
|
||||||
for (let i = 0; i < numparts; i++) {
|
const lastDir = dirparts[dirparts.length - 1]
|
||||||
const dirpart = dirparts.shift()
|
const shouldUseFileAsLibraryItem = directMediaFileCountByDir[dirPath] === 1 && hasNestedNonDiscMediaByDir[dirPath]
|
||||||
_path = Path.posix.join(_path, dirpart)
|
|
||||||
|
|
||||||
if (libraryItemGroup[_path]) {
|
if (shouldUseFileAsLibraryItem) {
|
||||||
// Directory already has files, add file
|
libraryItemGroup[item.path] = [item.path]
|
||||||
const relpath = Path.posix.join(dirparts.join('/'), item.name)
|
return
|
||||||
libraryItemGroup[_path].push(relpath)
|
|
||||||
return
|
|
||||||
} else if (!dirparts.length) {
|
|
||||||
// This is the last directory, create group
|
|
||||||
libraryItemGroup[_path] = [item.name]
|
|
||||||
return
|
|
||||||
} else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) {
|
|
||||||
// Next directory is the last and is a CD dir, create group
|
|
||||||
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupPath = isDiscDirectoryName(lastDir) && dirparts.length > 1 ? dirparts.slice(0, -1).join('/') : dirPath
|
||||||
|
const relpath = Path.posix.relative(groupPath, item.path)
|
||||||
|
if (!libraryItemGroup[groupPath]) {
|
||||||
|
libraryItemGroup[groupPath] = []
|
||||||
|
}
|
||||||
|
libraryItemGroup[groupPath].push(relpath)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 4: Add other files into library item groups
|
// Step 4: Add other files into library item groups
|
||||||
otherFileItems.forEach((item) => {
|
otherFileItems.forEach((item) => {
|
||||||
const dirparts = item.reldirpath.split('/')
|
const dirPath = item.reldirpath || ''
|
||||||
const numparts = dirparts.length
|
const itemPath = item.path
|
||||||
let _path = ''
|
const sameDirFileGroups = Object.keys(libraryItemGroup).filter((groupPath) => {
|
||||||
|
if (!groupPath || !Path.posix.extname(groupPath)) return false
|
||||||
|
return Path.posix.dirname(groupPath) === dirPath
|
||||||
|
})
|
||||||
|
|
||||||
// Iterate over directories in path
|
if (sameDirFileGroups.length) {
|
||||||
for (let i = 0; i < numparts; i++) {
|
const itemStem = Path.basename(item.name, item.extension)
|
||||||
const dirpart = dirparts.shift()
|
const matchingFileGroup =
|
||||||
_path = Path.posix.join(_path, dirpart)
|
sameDirFileGroups.find((groupPath) => Path.basename(groupPath, Path.extname(groupPath)) === itemStem) ||
|
||||||
if (libraryItemGroup[_path]) {
|
(sameDirFileGroups.length === 1 ? sameDirFileGroups[0] : null)
|
||||||
// Directory is audiobook group
|
|
||||||
const relpath = Path.posix.join(dirparts.join('/'), item.name)
|
if (matchingFileGroup) {
|
||||||
libraryItemGroup[_path].push(relpath)
|
if (Array.isArray(libraryItemGroup[matchingFileGroup])) {
|
||||||
|
libraryItemGroup[matchingFileGroup].push(itemPath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirparts = dirPath.split('/').filter(Boolean)
|
||||||
|
for (let i = dirparts.length; i >= 1; i--) {
|
||||||
|
const groupPath = dirparts.slice(0, i).join('/')
|
||||||
|
if (Array.isArray(libraryItemGroup[groupPath])) {
|
||||||
|
const relpath = Path.posix.relative(groupPath, itemPath)
|
||||||
|
libraryItemGroup[groupPath].push(relpath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
272
test/server/providers/OpenAI.test.js
Normal file
272
test/server/providers/OpenAI.test.js
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
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('skips a series assignment without sequence', () => {
|
||||||
|
const result = openAI.validateSeriesDetectionPayload(
|
||||||
|
{
|
||||||
|
books: [
|
||||||
|
{ id: 'a', seriesName: 'Series Name', sequence: null, reason: 'folder match' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ id: 'a' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0].seriesName).to.equal(null)
|
||||||
|
expect(result[0].sequence).to.equal(null)
|
||||||
|
expect(result[0].reason).to.contain('skipped due to missing or invalid sequence')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips a sequence without series name', () => {
|
||||||
|
const result = openAI.validateSeriesDetectionPayload(
|
||||||
|
{
|
||||||
|
books: [
|
||||||
|
{ id: 'a', seriesName: null, sequence: '2', reason: 'sequence found' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ id: 'a' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0].seriesName).to.equal(null)
|
||||||
|
expect(result[0].sequence).to.equal(null)
|
||||||
|
expect(result[0].reason).to.contain('skipped due to missing series name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores unknown ids and backfills missing ids', () => {
|
||||||
|
const result = openAI.validateSeriesDetectionPayload(
|
||||||
|
{
|
||||||
|
books: [
|
||||||
|
{ id: 'z', seriesName: 'Wrong Series', sequence: '9' },
|
||||||
|
{ id: 'a', seriesName: 'Series Name', sequence: '1' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ id: 'a' }, { id: 'b' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).to.have.length(2)
|
||||||
|
expect(result[0].id).to.equal('a')
|
||||||
|
expect(result[0].seriesName).to.equal('Series Name')
|
||||||
|
expect(result[1].id).to.equal('b')
|
||||||
|
expect(result[1].seriesName).to.equal(null)
|
||||||
|
expect(result[1].reason).to.contain('omitted this book')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores duplicate ids in detection payload', () => {
|
||||||
|
const result = openAI.validateSeriesDetectionPayload(
|
||||||
|
{
|
||||||
|
books: [
|
||||||
|
{ id: 'a', seriesName: 'Series Name', sequence: '1' },
|
||||||
|
{ id: 'a', seriesName: 'Other Series', sequence: '2' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ id: 'a' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).to.have.length(1)
|
||||||
|
expect(result[0].seriesName).to.equal('Series Name')
|
||||||
|
expect(result[0].sequence).to.equal('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateScanMetadataPayload', () => {
|
||||||
|
it('normalizes valid scan metadata', () => {
|
||||||
|
const result = openAI.validateScanMetadataPayload({
|
||||||
|
book: {
|
||||||
|
title: ' Neuromancer ',
|
||||||
|
subtitle: '20th Anniversary Edition',
|
||||||
|
authors: ['William Gibson', 'William Gibson'],
|
||||||
|
narrators: ['Robertson Dean'],
|
||||||
|
seriesName: 'Sprawl Trilogy',
|
||||||
|
sequence: '1',
|
||||||
|
publishedYear: '1984',
|
||||||
|
asin: 'B000FC11ZG',
|
||||||
|
isbn: '978-0441569595',
|
||||||
|
language: 'English',
|
||||||
|
reason: 'path and tags match'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.title).to.equal('Neuromancer')
|
||||||
|
expect(result.authors).to.deep.equal(['William Gibson'])
|
||||||
|
expect(result.seriesName).to.equal('Sprawl Trilogy')
|
||||||
|
expect(result.sequence).to.equal('1')
|
||||||
|
expect(result.asin).to.equal('B000FC11ZG')
|
||||||
|
expect(result.isbn).to.equal('9780441569595')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for malformed scan metadata fields', () => {
|
||||||
|
const result = openAI.validateScanMetadataPayload({
|
||||||
|
title: '',
|
||||||
|
authors: [''],
|
||||||
|
seriesName: 'Series Name',
|
||||||
|
sequence: 'not-a-sequence',
|
||||||
|
publishedYear: '84',
|
||||||
|
asin: 'bad',
|
||||||
|
isbn: 'nope'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.title).to.equal(null)
|
||||||
|
expect(result.authors).to.deep.equal([])
|
||||||
|
expect(result.seriesName).to.equal('Series Name')
|
||||||
|
expect(result.sequence).to.equal(null)
|
||||||
|
expect(result.publishedYear).to.equal(null)
|
||||||
|
expect(result.asin).to.equal(null)
|
||||||
|
expect(result.isbn).to.equal(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateDirectoryGroupingPayload', () => {
|
||||||
|
it('normalizes valid directory-grouping payload', () => {
|
||||||
|
const result = openAI.validateDirectoryGroupingPayload(
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
{ path: 'Series Alpha/Book One.m4b', groupId: ' book-one ', reason: 'same book' },
|
||||||
|
{ path: 'Series Alpha/Disc 1/Book Two Part 1.mp3', groupId: 'book-two', reason: 'disc set' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ path: 'Series Alpha/Book One.m4b' }, { path: 'Series Alpha/Disc 1/Book Two Part 1.mp3' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0].groupId).to.equal('book-one')
|
||||||
|
expect(result[1].groupId).to.equal('book-two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('backfills missing or invalid grouping rows', () => {
|
||||||
|
const result = openAI.validateDirectoryGroupingPayload(
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
{ path: 'unknown/path.m4b', groupId: 'ignored' },
|
||||||
|
{ path: 'Series Alpha/Book One.m4b', groupId: '' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ path: 'Series Alpha/Book One.m4b' }, { path: 'Series Alpha/Book Two.m4b' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result[0].groupId).to.equal('Series Alpha/Book One.m4b')
|
||||||
|
expect(result[1].groupId).to.equal('Series Alpha/Book Two.m4b')
|
||||||
|
expect(result[1].reason).to.contain('omitted this media file')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateDuplicateBooksPayload', () => {
|
||||||
|
it('normalizes valid duplicate-book groups', () => {
|
||||||
|
const result = openAI.validateDuplicateBooksPayload(
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
keepId: 'a',
|
||||||
|
duplicateIds: ['b', 'c'],
|
||||||
|
reason: 'same book'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ id: 'a' }, { id: 'b' }, { id: 'c' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).to.deep.equal([
|
||||||
|
{
|
||||||
|
keepId: 'a',
|
||||||
|
duplicateIds: ['b', 'c'],
|
||||||
|
reason: 'same book'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores invalid and overlapping duplicate-book groups', () => {
|
||||||
|
const result = openAI.validateDuplicateBooksPayload(
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
keepId: 'a',
|
||||||
|
duplicateIds: ['b', 'missing', 'a'],
|
||||||
|
reason: 'primary match'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepId: 'b',
|
||||||
|
duplicateIds: ['c'],
|
||||||
|
reason: 'should be skipped because b was consumed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepId: 'z',
|
||||||
|
duplicateIds: ['c']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[{ id: 'a' }, { id: 'b' }, { id: 'c' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).to.deep.equal([
|
||||||
|
{
|
||||||
|
keepId: 'a',
|
||||||
|
duplicateIds: ['b'],
|
||||||
|
reason: 'primary match'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -33,6 +33,7 @@ describe('scanUtils', async () => {
|
||||||
const dirname = Path.dirname(filePath)
|
const dirname = Path.dirname(filePath)
|
||||||
fileItems.push({
|
fileItems.push({
|
||||||
name: Path.basename(filePath),
|
name: Path.basename(filePath),
|
||||||
|
path: filePath,
|
||||||
reldirpath: dirname === '.' ? '' : dirname,
|
reldirpath: dirname === '.' ? '' : dirname,
|
||||||
extension: Path.extname(filePath),
|
extension: Path.extname(filePath),
|
||||||
deep: filePath.split('/').length - 1
|
deep: filePath.split('/').length - 1
|
||||||
|
|
@ -49,4 +50,43 @@ describe('scanUtils', async () => {
|
||||||
'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3']
|
'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3']
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should keep nested book folders separate when a parent folder also contains a direct media file', async () => {
|
||||||
|
const filePaths = [
|
||||||
|
'Series Alpha/Standalone Side Story.m4b',
|
||||||
|
'Series Alpha/Standalone Side Story.nfo',
|
||||||
|
'Series Alpha/Author Example - Book One Main Saga, Book 1/Book One Main Saga, Book 1.m4b',
|
||||||
|
'Series Alpha/Author Example - Book One Main Saga, Book 1/Book One Main Saga, Book 1.cue',
|
||||||
|
'Series Alpha/Author Example - Book Two Main Saga, Book 2/Book Two Main Saga, Book 2.m4b',
|
||||||
|
'Series Alpha/Author Example - Book Two Main Saga, Book 2/Book Two Main Saga, Book 2.nfo'
|
||||||
|
]
|
||||||
|
|
||||||
|
const fileItems = filePaths.map((filePath) => {
|
||||||
|
const dirname = Path.dirname(filePath)
|
||||||
|
return {
|
||||||
|
name: Path.basename(filePath),
|
||||||
|
path: filePath,
|
||||||
|
reldirpath: dirname === '.' ? '' : dirname,
|
||||||
|
extension: Path.extname(filePath),
|
||||||
|
deep: filePath.split('/').length - 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs('book', fileItems, false)
|
||||||
|
|
||||||
|
expect(libraryItemGrouping).to.deep.equal({
|
||||||
|
'Series Alpha/Standalone Side Story.m4b': [
|
||||||
|
'Series Alpha/Standalone Side Story.m4b',
|
||||||
|
'Series Alpha/Standalone Side Story.nfo'
|
||||||
|
],
|
||||||
|
'Series Alpha/Author Example - Book One Main Saga, Book 1': [
|
||||||
|
'Book One Main Saga, Book 1.m4b',
|
||||||
|
'Book One Main Saga, Book 1.cue'
|
||||||
|
],
|
||||||
|
'Series Alpha/Author Example - Book Two Main Saga, Book 2': [
|
||||||
|
'Book Two Main Saga, Book 2.m4b',
|
||||||
|
'Book Two Main Saga, Book 2.nfo'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue