mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-28 14:21:34 +00:00
update
This commit is contained in:
parent
e5261d137f
commit
58776ca983
6 changed files with 352 additions and 2 deletions
|
|
@ -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>
|
||||||
|
|
@ -92,7 +99,8 @@ export default {
|
||||||
include: true
|
include: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
metadataSourceMapped: []
|
metadataSourceMapped: [],
|
||||||
|
openAIDirectoryGrouping: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -131,6 +139,7 @@ export default {
|
||||||
metadataSourceIds.reverse()
|
metadataSourceIds.reverse()
|
||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
|
openAIDirectoryGrouping: !!this.openAIDirectoryGrouping,
|
||||||
metadataPrecedence: metadataSourceIds
|
metadataPrecedence: metadataSourceIds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,14 @@ class OpenAI {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summarizeDirectoryGroupingForLog(grouping) {
|
||||||
|
return JSON.stringify({
|
||||||
|
path: grouping.path,
|
||||||
|
groupId: grouping.groupId,
|
||||||
|
reason: grouping.reason || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
normalizePathForPrompt(filePath) {
|
normalizePathForPrompt(filePath) {
|
||||||
if (!filePath || typeof filePath !== 'string') return null
|
if (!filePath || typeof filePath !== 'string') return null
|
||||||
return filePath.replace(/\\/g, '/')
|
return filePath.replace(/\\/g, '/')
|
||||||
|
|
@ -272,6 +280,42 @@ class OpenAI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
validateBookIds(resultBooks, books) {
|
validateBookIds(resultBooks, books) {
|
||||||
if (!Array.isArray(resultBooks) || resultBooks.length !== books.length) {
|
if (!Array.isArray(resultBooks) || resultBooks.length !== books.length) {
|
||||||
throw new Error('OpenAI returned an invalid number of books')
|
throw new Error('OpenAI returned an invalid number of books')
|
||||||
|
|
@ -649,6 +693,55 @@ ${JSON.stringify(ebookMetadata, null, 2)}`
|
||||||
Logger.info(`[OpenAI] Scan-metadata result for "${libraryItemData.relPath}" ${this.summarizeScanMetadataForLog(validated)}`)
|
Logger.info(`[OpenAI] Scan-metadata result for "${libraryItemData.relPath}" ${this.summarizeScanMetadataForLog(validated)}`)
|
||||||
return 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = OpenAI
|
module.exports = OpenAI
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
@ -368,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
|
||||||
|
|
@ -650,6 +853,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,4 +179,37 @@ describe('OpenAI', () => {
|
||||||
expect(result.isbn).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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue