From e5261d137f8fcdeb41ac8a958fe683dd410a45bf Mon Sep 17 00:00:00 2001 From: korjik Date: Wed, 22 Apr 2026 09:32:39 -0700 Subject: [PATCH] update --- server/scanner/LibraryScanner.js | 16 ++++-- server/utils/scandir.js | 94 ++++++++++++++++++++----------- test/server/utils/scandir.test.js | 40 +++++++++++++ 3 files changed, 111 insertions(+), 39 deletions(-) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 640c82d76..636703226 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -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) { diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 6dd2d67fe..7f3110952 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -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 } } diff --git a/test/server/utils/scandir.test.js b/test/server/utils/scandir.test.js index a5ff6ae0e..a3f10dafc 100644 --- a/test/server/utils/scandir.test.js +++ b/test/server/utils/scandir.test.js @@ -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' + ] + }) + }) })