diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 20bd230159c284..fa26db8fe90479 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -347,6 +347,56 @@ describe('/libraries', () => { expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); }); + it('should scan multiple import paths with commas', async () => { + // https://github.com/immich-app/immich/issues/10699 + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`], + }); + + utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(2); + expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined(); + expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined(); + }); + + it('should scan multiple import paths with braces', async () => { + // https://github.com/immich-app/immich/issues/10699 + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`], + }); + + utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(2); + expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined(); + expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined(); + }); + it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 6fd9bb8b041472..99df0848eff142 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; -import { escapePath, glob, globStream } from 'fast-glob'; +import { escapePath, glob, globStream, globSync } from 'fast-glob'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -156,7 +156,9 @@ export class StorageRepository implements IStorageRepository { return Promise.resolve([]); } - return glob(this.asGlob(pathsToCrawl), { + const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); + + return glob(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, @@ -172,10 +174,15 @@ export class StorageRepository implements IStorageRepository { return emptyGenerator(); } - const stream = globStream(this.asGlob(pathsToCrawl), { + const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); + + this.logger.debug(globbedPaths); + + const stream = globStream(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, + unique: true, dot: includeHidden, ignore: exclusionPatterns, }); @@ -206,10 +213,9 @@ export class StorageRepository implements IStorageRepository { return () => watcher.close(); } - private asGlob(pathsToCrawl: string[]): string { - const escapedPaths = pathsToCrawl.map((path) => escapePath(path)); - const base = escapedPaths.length === 1 ? escapedPaths[0] : `{${escapedPaths.join(',')}}`; + private asGlob(pathToCrawl: string): string { + const escapedPath = escapePath(pathToCrawl); const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`; - return `${base}/**/${extensions}`; + return `${escapedPath}/**/${extensions}`; } }