mirror of
https://github.com/immich-app/immich.git
synced 2026-05-15 20:12:13 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa9baad116 | |||
| 3e43da5d8d | |||
| 292fbef180 | |||
| 05b07ca233 |
Generated
+49
-3
@@ -431,8 +431,8 @@ importers:
|
|||||||
specifier: ^5.51.0
|
specifier: ^5.51.0
|
||||||
version: 5.70.4
|
version: 5.70.4
|
||||||
chokidar:
|
chokidar:
|
||||||
specifier: ^4.0.3
|
specifier: ^5.0.0
|
||||||
version: 4.0.3
|
version: 5.0.0
|
||||||
class-transformer:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
@@ -592,7 +592,7 @@ importers:
|
|||||||
version: 11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)(esbuild@0.27.3)
|
version: 11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)(esbuild@0.27.3)
|
||||||
'@nestjs/schematics':
|
'@nestjs/schematics':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
|
version: 11.0.9(chokidar@5.0.0)(typescript@5.9.3)
|
||||||
'@nestjs/testing':
|
'@nestjs/testing':
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
|
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
|
||||||
@@ -6050,6 +6050,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
|
|
||||||
|
chokidar@5.0.0:
|
||||||
|
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
chownr@1.1.4:
|
chownr@1.1.4:
|
||||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||||
|
|
||||||
@@ -10540,6 +10544,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
||||||
|
readdirp@5.0.0:
|
||||||
|
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
recma-build-jsx@1.0.0:
|
recma-build-jsx@1.0.0:
|
||||||
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
||||||
|
|
||||||
@@ -12602,6 +12610,17 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
|
|
||||||
|
'@angular-devkit/core@19.2.17(chokidar@5.0.0)':
|
||||||
|
dependencies:
|
||||||
|
ajv: 8.17.1
|
||||||
|
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||||
|
jsonc-parser: 3.3.1
|
||||||
|
picomatch: 4.0.2
|
||||||
|
rxjs: 7.8.1
|
||||||
|
source-map: 0.7.4
|
||||||
|
optionalDependencies:
|
||||||
|
chokidar: 5.0.0
|
||||||
|
|
||||||
'@angular-devkit/core@19.2.19(chokidar@4.0.3)':
|
'@angular-devkit/core@19.2.19(chokidar@4.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 8.17.1
|
ajv: 8.17.1
|
||||||
@@ -12635,6 +12654,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- chokidar
|
||||||
|
|
||||||
|
'@angular-devkit/schematics@19.2.17(chokidar@5.0.0)':
|
||||||
|
dependencies:
|
||||||
|
'@angular-devkit/core': 19.2.17(chokidar@5.0.0)
|
||||||
|
jsonc-parser: 3.3.1
|
||||||
|
magic-string: 0.30.17
|
||||||
|
ora: 5.4.1
|
||||||
|
rxjs: 7.8.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- chokidar
|
||||||
|
|
||||||
'@angular-devkit/schematics@19.2.19(chokidar@4.0.3)':
|
'@angular-devkit/schematics@19.2.19(chokidar@4.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
|
||||||
@@ -15720,6 +15749,17 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- chokidar
|
||||||
|
|
||||||
|
'@nestjs/schematics@11.0.9(chokidar@5.0.0)(typescript@5.9.3)':
|
||||||
|
dependencies:
|
||||||
|
'@angular-devkit/core': 19.2.17(chokidar@5.0.0)
|
||||||
|
'@angular-devkit/schematics': 19.2.17(chokidar@5.0.0)
|
||||||
|
comment-json: 4.4.1
|
||||||
|
jsonc-parser: 3.3.1
|
||||||
|
pluralize: 8.0.0
|
||||||
|
typescript: 5.9.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- chokidar
|
||||||
|
|
||||||
'@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
'@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@microsoft/tsdoc': 0.16.0
|
'@microsoft/tsdoc': 0.16.0
|
||||||
@@ -18441,6 +18481,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
|
|
||||||
|
chokidar@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
readdirp: 5.0.0
|
||||||
|
|
||||||
chownr@1.1.4: {}
|
chownr@1.1.4: {}
|
||||||
|
|
||||||
chownr@2.0.0: {}
|
chownr@2.0.0: {}
|
||||||
@@ -23672,6 +23716,8 @@ snapshots:
|
|||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
|
readdirp@5.0.0: {}
|
||||||
|
|
||||||
recma-build-jsx@1.0.0:
|
recma-build-jsx@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"bullmq": "^5.51.0",
|
"bullmq": "^5.51.0",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^5.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.0",
|
"class-validator": "^0.15.0",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ import { JobOf } from 'src/types';
|
|||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { handlePromiseError } from 'src/utils/misc';
|
import { handlePromiseError } from 'src/utils/misc';
|
||||||
|
|
||||||
|
const createMatchers = (exclusionPatterns: string[]) => {
|
||||||
|
const supportedExtensions = mimeTypes.getSupportedFileExtensions().map((extension) => extension.toLowerCase());
|
||||||
|
const expandedPatterns = exclusionPatterns.flatMap((pattern) =>
|
||||||
|
pattern.endsWith('/**') ? [pattern, pattern.slice(0, -3)] : [pattern],
|
||||||
|
);
|
||||||
|
const excludeMatcher = picomatch(expandedPatterns, { nocase: true });
|
||||||
|
return {
|
||||||
|
isExcluded: (path: string) => excludeMatcher(path.replaceAll('\\', '/')),
|
||||||
|
isSupported: (path: string) => {
|
||||||
|
const normalizedPath = path.toLowerCase();
|
||||||
|
return supportedExtensions.some((extension) => normalizedPath.endsWith(extension));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LibraryService extends BaseService {
|
export class LibraryService extends BaseService {
|
||||||
private watchLibraries = false;
|
private watchLibraries = false;
|
||||||
@@ -90,24 +105,24 @@ export class LibraryService extends BaseService {
|
|||||||
|
|
||||||
this.logger.log(`Starting to watch library ${library.id} with import path(s) ${library.importPaths}`);
|
this.logger.log(`Starting to watch library ${library.id} with import path(s) ${library.importPaths}`);
|
||||||
|
|
||||||
const matcher = picomatch(`**/*{${mimeTypes.getSupportedFileExtensions().join(',')}}`, {
|
const { isExcluded, isSupported } = createMatchers(library.exclusionPatterns);
|
||||||
nocase: true,
|
|
||||||
ignore: library.exclusionPatterns,
|
|
||||||
});
|
|
||||||
|
|
||||||
let _resolve: () => void;
|
let _resolve: () => void;
|
||||||
const ready$ = new Promise<void>((resolve) => (_resolve = resolve));
|
const ready$ = new Promise<void>((resolve) => (_resolve = resolve));
|
||||||
|
|
||||||
const handler = async (event: string, path: string) => {
|
const handler = async (event: string, path: string) => {
|
||||||
if (matcher(path)) {
|
const ignored = !isSupported(path);
|
||||||
this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`);
|
|
||||||
await this.jobRepository.queue({
|
if (ignored) {
|
||||||
name: JobName.LibrarySyncFiles,
|
|
||||||
data: { libraryId: library.id, paths: [path] },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logger.verbose(`Ignoring file ${event} event for ${path} in library ${library.id}`);
|
this.logger.verbose(`Ignoring file ${event} event for ${path} in library ${library.id}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`);
|
||||||
|
await this.jobRepository.queue({
|
||||||
|
name: JobName.LibrarySyncFiles,
|
||||||
|
data: { libraryId: library.id, paths: [path] },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deletionHandler = async (path: string) => {
|
const deletionHandler = async (path: string) => {
|
||||||
@@ -123,6 +138,7 @@ export class LibraryService extends BaseService {
|
|||||||
{
|
{
|
||||||
usePolling: false,
|
usePolling: false,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
|
ignored: isExcluded,
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
stabilityThreshold: 5000,
|
stabilityThreshold: 5000,
|
||||||
pollInterval: 1000,
|
pollInterval: 1000,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
|
|||||||
import { EmailRepository } from 'src/repositories/email.repository';
|
import { EmailRepository } from 'src/repositories/email.repository';
|
||||||
import { EventRepository } from 'src/repositories/event.repository';
|
import { EventRepository } from 'src/repositories/event.repository';
|
||||||
import { JobRepository } from 'src/repositories/job.repository';
|
import { JobRepository } from 'src/repositories/job.repository';
|
||||||
|
import { LibraryRepository } from 'src/repositories/library.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
|
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
|
||||||
import { MapRepository } from 'src/repositories/map.repository';
|
import { MapRepository } from 'src/repositories/map.repository';
|
||||||
@@ -405,6 +406,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
|||||||
case AssetRepository:
|
case AssetRepository:
|
||||||
case AssetEditRepository:
|
case AssetEditRepository:
|
||||||
case AssetJobRepository:
|
case AssetJobRepository:
|
||||||
|
case LibraryRepository:
|
||||||
case MemoryRepository:
|
case MemoryRepository:
|
||||||
case NotificationRepository:
|
case NotificationRepository:
|
||||||
case OcrRepository:
|
case OcrRepository:
|
||||||
@@ -466,6 +468,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
|||||||
case AlbumRepository:
|
case AlbumRepository:
|
||||||
case AssetRepository:
|
case AssetRepository:
|
||||||
case AssetJobRepository:
|
case AssetJobRepository:
|
||||||
|
case LibraryRepository:
|
||||||
case ConfigRepository:
|
case ConfigRepository:
|
||||||
case CryptoRepository:
|
case CryptoRepository:
|
||||||
case MemoryRepository:
|
case MemoryRepository:
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
const { ctx } = newMediumService(BaseService, {
|
||||||
|
database: db || defaultDatabase,
|
||||||
|
real: [],
|
||||||
|
mock: [LoggingRepository],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ctx, sut: ctx.get(StorageRepository) };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
defaultDatabase = await getKyselyDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchForEvent = (
|
||||||
|
sut: StorageRepository,
|
||||||
|
folder: string,
|
||||||
|
event: 'add' | 'change' | 'unlink',
|
||||||
|
action: () => Promise<void>,
|
||||||
|
): Promise<string> => {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
void close().finally(() => reject(new Error(`Timed out waiting for ${event} event`)));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
const onResolve = (path: string) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
void close().finally(() => resolve(path));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReject = (error: Error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
void close().finally(() => reject(error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = sut.watch(
|
||||||
|
[folder],
|
||||||
|
{
|
||||||
|
ignoreInitial: true,
|
||||||
|
usePolling: true,
|
||||||
|
interval: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReady: () => {
|
||||||
|
void action().catch((error) => onReject(error as Error));
|
||||||
|
},
|
||||||
|
onAdd: (path) => {
|
||||||
|
if (event === 'add') {
|
||||||
|
onResolve(path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChange: (path) => {
|
||||||
|
if (event === 'change') {
|
||||||
|
onResolve(path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUnlink: (path) => {
|
||||||
|
if (event === 'unlink') {
|
||||||
|
onResolve(path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => onReject(error),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(StorageRepository.name, () => {
|
||||||
|
describe('watch', () => {
|
||||||
|
it('should fire create (add) events', async () => {
|
||||||
|
const { sut } = setup();
|
||||||
|
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-add-'));
|
||||||
|
const file = join(folder, 'created.jpg');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changedPath = await watchForEvent(sut, folder, 'add', async () => {
|
||||||
|
await writeFile(file, 'created');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(changedPath).toBe(file);
|
||||||
|
} finally {
|
||||||
|
await rm(folder, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire change events', async () => {
|
||||||
|
const { sut } = setup();
|
||||||
|
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-change-'));
|
||||||
|
const file = join(folder, 'changed.jpg');
|
||||||
|
|
||||||
|
await writeFile(file, 'before');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changedPath = await watchForEvent(sut, folder, 'change', async () => {
|
||||||
|
await writeFile(file, 'after');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(changedPath).toBe(file);
|
||||||
|
} finally {
|
||||||
|
await rm(folder, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire unlink events', async () => {
|
||||||
|
const { sut } = setup();
|
||||||
|
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-unlink-'));
|
||||||
|
const file = join(folder, 'deleted.jpg');
|
||||||
|
|
||||||
|
await writeFile(file, 'content');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changedPath = await watchForEvent(sut, folder, 'unlink', async () => {
|
||||||
|
await unlink(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(changedPath).toBe(file);
|
||||||
|
} finally {
|
||||||
|
await rm(folder, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { ChokidarOptions } from 'chokidar';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { JobName } from 'src/enum';
|
||||||
|
import { EventRepository } from 'src/repositories/event.repository';
|
||||||
|
import { JobRepository } from 'src/repositories/job.repository';
|
||||||
|
import { LibraryRepository } from 'src/repositories/library.repository';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository, WatchEvents } from 'src/repositories/storage.repository';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { LibraryService } from 'src/services/library.service';
|
||||||
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
return newMediumService(LibraryService, {
|
||||||
|
database: db || defaultDatabase,
|
||||||
|
real: [LibraryRepository],
|
||||||
|
mock: [EventRepository, JobRepository, LoggingRepository, StorageRepository],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
defaultDatabase = await getKyselyDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeWatcher = () => Promise.resolve();
|
||||||
|
|
||||||
|
const setWatchMode = (sut: LibraryService) => {
|
||||||
|
const service = sut as unknown as { lock: boolean; watchLibraries: boolean };
|
||||||
|
service.lock = true;
|
||||||
|
service.watchLibraries = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMockWatcher =
|
||||||
|
(paths: { add?: string[]; change?: string[]; unlink?: string[] }) =>
|
||||||
|
(_paths: string[], options: ChokidarOptions, events: Partial<WatchEvents>): (() => Promise<void>) => {
|
||||||
|
const ignored = options.ignored as ((path: string) => boolean) | undefined;
|
||||||
|
events.onReady?.();
|
||||||
|
for (const path of paths.add ?? []) {
|
||||||
|
if (!ignored?.(path)) {
|
||||||
|
events.onAdd?.(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of paths.change ?? []) {
|
||||||
|
if (!ignored?.(path)) {
|
||||||
|
events.onChange?.(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of paths.unlink ?? []) {
|
||||||
|
if (!ignored?.(path)) {
|
||||||
|
events.onUnlink?.(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closeWatcher;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(`${LibraryService.name} (watch, medium)`, () => {
|
||||||
|
it('should queue add and change events for supported files', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const storageRepo = ctx.getMock(StorageRepository);
|
||||||
|
const jobRepo = ctx.getMock(JobRepository);
|
||||||
|
|
||||||
|
jobRepo.queue.mockResolvedValue();
|
||||||
|
setWatchMode(sut);
|
||||||
|
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const library = await sut.create({
|
||||||
|
ownerId: user.id,
|
||||||
|
importPaths: ['/test-assets/temp/watcher-behavior'],
|
||||||
|
exclusionPatterns: ['**/@eaDir/**'],
|
||||||
|
});
|
||||||
|
|
||||||
|
storageRepo.watch.mockImplementation(
|
||||||
|
makeMockWatcher({
|
||||||
|
add: ['/test-assets/temp/watcher-behavior/add.png'],
|
||||||
|
change: ['/test-assets/temp/watcher-behavior/change.jpg'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.watchAll();
|
||||||
|
|
||||||
|
expect(jobRepo.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.LibrarySyncFiles,
|
||||||
|
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/add.png'] },
|
||||||
|
});
|
||||||
|
expect(jobRepo.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.LibrarySyncFiles,
|
||||||
|
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/change.jpg'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sut.onShutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should queue unlink events for tracked files', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const storageRepo = ctx.getMock(StorageRepository);
|
||||||
|
const jobRepo = ctx.getMock(JobRepository);
|
||||||
|
|
||||||
|
jobRepo.queue.mockResolvedValue();
|
||||||
|
setWatchMode(sut);
|
||||||
|
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const library = await sut.create({
|
||||||
|
ownerId: user.id,
|
||||||
|
importPaths: ['/test-assets/temp/watcher-behavior'],
|
||||||
|
exclusionPatterns: ['**/@eaDir/**'],
|
||||||
|
});
|
||||||
|
|
||||||
|
storageRepo.watch.mockImplementation(
|
||||||
|
makeMockWatcher({
|
||||||
|
unlink: ['/test-assets/temp/watcher-behavior/delete.png'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.watchAll();
|
||||||
|
|
||||||
|
expect(jobRepo.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.LibraryRemoveAsset,
|
||||||
|
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/delete.png'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sut.onShutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore add, change, and unlink events in excluded directories', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const storageRepo = ctx.getMock(StorageRepository);
|
||||||
|
const jobRepo = ctx.getMock(JobRepository);
|
||||||
|
|
||||||
|
jobRepo.queue.mockResolvedValue();
|
||||||
|
setWatchMode(sut);
|
||||||
|
|
||||||
|
await ctx.newUser().then(({ user }) =>
|
||||||
|
sut.create({
|
||||||
|
ownerId: user.id,
|
||||||
|
importPaths: ['/test-assets/temp/watcher-behavior'],
|
||||||
|
exclusionPatterns: ['**/@eaDir/**'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
storageRepo.watch.mockImplementation(
|
||||||
|
makeMockWatcher({
|
||||||
|
add: ['/test-assets/temp/watcher-behavior/@eaDir/add.png'],
|
||||||
|
change: ['/test-assets/temp/watcher-behavior/@eaDir/change.png'],
|
||||||
|
unlink: ['/test-assets/temp/watcher-behavior/@eaDir/unlink.png'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.watchAll();
|
||||||
|
|
||||||
|
expect(jobRepo.queue).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: JobName.LibrarySyncFiles,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(jobRepo.queue).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: JobName.LibraryRemoveAsset,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.onShutdown();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user