This commit is contained in:
korjik 2026-04-22 09:32:39 -07:00
parent f4ce4a4bde
commit e5261d137f
3 changed files with 111 additions and 39 deletions

View file

@ -321,8 +321,12 @@ class LibraryScanner {
let isFile = false // item is not in a folder
let libraryItemData = null
let fileObjs = []
if (libraryItemPath === libraryItemGrouping[libraryItemPath]) {
// Media file in root only get title
const groupedFiles = libraryItemGrouping[libraryItemPath]
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 = {
mediaMetadata: {
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
@ -330,11 +334,11 @@ class LibraryScanner {
path: Path.posix.join(folderPath, libraryItemPath),
relPath: libraryItemPath
}
fileObjs = await scanUtils.buildLibraryFile(folderPath, [libraryItemPath])
fileObjs = await scanUtils.buildLibraryFile(folderPath, Array.isArray(groupedFiles) ? groupedFiles : [libraryItemPath])
isFile = true
} else {
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)
@ -651,11 +655,11 @@ function ItemToItemInoMatch(libraryItem1, libraryItem2) {
}
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) {
return itemDir === fileUpdateGroup[itemDir]
return itemDir === fileUpdateGroup[itemDir] || (Array.isArray(fileUpdateGroup[itemDir]) && fileUpdateGroup[itemDir].includes(itemDir))
}
async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) {

View file

@ -30,6 +30,10 @@ function isScannableNonMediaFile(ext) {
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) {
const ext = Path.extname(filepath)
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
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) => {
const dirparts = item.reldirpath.split('/').filter((p) => !!p)
const numparts = dirparts.length
let _path = ''
if (!dirparts.length) {
// Media file in root
libraryItemGroup[item.name] = item.name
libraryItemGroup[item.path] = item.path
} else {
// Iterate over directories in path
for (let i = 0; i < numparts; i++) {
const dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
const dirPath = dirparts.join('/')
const lastDir = dirparts[dirparts.length - 1]
const shouldUseFileAsLibraryItem = directMediaFileCountByDir[dirPath] === 1 && hasNestedNonDiscMediaByDir[dirPath]
if (libraryItemGroup[_path]) {
// Directory already has files, add file
const relpath = Path.posix.join(dirparts.join('/'), item.name)
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
}
if (shouldUseFileAsLibraryItem) {
libraryItemGroup[item.path] = [item.path]
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
otherFileItems.forEach((item) => {
const dirparts = item.reldirpath.split('/')
const numparts = dirparts.length
let _path = ''
const dirPath = item.reldirpath || ''
const itemPath = item.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
for (let i = 0; i < numparts; i++) {
const dirpart = dirparts.shift()
_path = Path.posix.join(_path, dirpart)
if (libraryItemGroup[_path]) {
// Directory is audiobook group
const relpath = Path.posix.join(dirparts.join('/'), item.name)
libraryItemGroup[_path].push(relpath)
if (sameDirFileGroups.length) {
const itemStem = Path.basename(item.name, item.extension)
const matchingFileGroup =
sameDirFileGroups.find((groupPath) => Path.basename(groupPath, Path.extname(groupPath)) === itemStem) ||
(sameDirFileGroups.length === 1 ? sameDirFileGroups[0] : null)
if (matchingFileGroup) {
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
}
}

View file

@ -33,6 +33,7 @@ describe('scanUtils', async () => {
const dirname = Path.dirname(filePath)
fileItems.push({
name: Path.basename(filePath),
path: filePath,
reldirpath: dirname === '.' ? '' : dirname,
extension: Path.extname(filePath),
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']
})
})
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'
]
})
})
})