From 068e703e8804e626f8729b85d33fcfc5e312fc59 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 31 Jan 2024 09:15:54 +0100 Subject: [PATCH] feat(server): Automatic watching of library folders (#6192) * feat: initial watch support * allow offline files * chore: ignore query errors when resetting e2e db * revert db query * add savepoint * guard the user query * chore: openapi and db migration * wip * support multiple libraries * fix tests * wip * can now cleanup chokidar watchers * fix unit tests * add library watch queue * add missing init from merge * wip * can now filter file extensions * remove watch api from non job client * Fix e2e test * watch library with updated import path and exclusion pattern * add library watch frontend ui * case sensitive watching extensions * can auto watch libraries * move watcher e2e tests to separate file * don't watch libraries from a queue * use event emitters * shorten e2e test timeout * refactor chokidar code to filesystem provider * expose chokidar parameters to config file * fix storage mock * set default config for library watching * add fs provider mocks * cleanup * add more unit tests for watcher * chore: fix format + sql * add more tests * move unwatch feature back to library service * add file event unit tests * chore: formatting * add documentation * fix e2e tests * chore: fix e2e tests * fix library updating * test cleanup * fix typo * cleanup * fixing as per pr comments * reduce library watch config file * update storage config and mocks * move negative event tests to unit tests * fix library watcher e2e * make watch configuration global * remove the feature flag * refactor watcher teardown * fix microservices init * centralize asset scan job queue * improve docs * add more tests * chore: open api * initialize app service * fix docs * fix library watch feature flag * Update docs/docs/features/libraries.md Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * fix: import right app service * don't be truthy * fix test speling * stricter library update tests * move fs watcher mock to external file * subscribe to config changes * docker does not need polling * make library watch() private * feat: add configuration ui --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Co-authored-by: Alex Tran --- docs/docs/features/libraries.md | 11 +- docs/docs/install/config-file.md | 5 + docs/src/pages/milestones.tsx | 10 + mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 24879 -> 24949 bytes mobile/openapi/doc/CreateLibraryDto.md | Bin 666 -> 708 bytes mobile/openapi/doc/SystemConfigLibraryDto.md | Bin 469 -> 553 bytes .../doc/SystemConfigLibraryWatchDto.md | Bin 0 -> 483 bytes mobile/openapi/lib/api.dart | Bin 8259 -> 8310 bytes mobile/openapi/lib/api_client.dart | Bin 23409 -> 23515 bytes .../openapi/lib/model/create_library_dto.dart | Bin 4936 -> 5627 bytes .../lib/model/system_config_library_dto.dart | Bin 2906 -> 3152 bytes .../system_config_library_watch_dto.dart | Bin 0 -> 3503 bytes .../openapi/test/create_library_dto_test.dart | Bin 1053 -> 1154 bytes .../test/system_config_library_dto_test.dart | Bin 602 -> 718 bytes .../system_config_library_watch_dto_test.dart | Bin 0 -> 802 bytes open-api/immich-openapi-specs.json | 28 +- open-api/typescript-sdk/client/api.ts | 37 ++ server/e2e/api/jest-e2e.json | 1 - server/e2e/client/library-api.ts | 16 +- .../config/library-watcher-e2e-config.json | 17 + server/e2e/jobs/immich-e2e-config.json | 3 +- server/e2e/jobs/jest-e2e.json | 2 +- .../jobs/specs/library-watcher.e2e-spec.ts | 235 ++++++++ server/e2e/jobs/specs/library.e2e-spec.ts | 10 +- server/e2e/jobs/specs/metadata.e2e-spec.ts | 7 +- server/package-lock.json | 129 +++- server/package.json | 3 + server/src/domain/library/library.dto.ts | 4 + .../domain/library/library.service.spec.ts | 560 ++++++++++++++++-- server/src/domain/library/library.service.ts | 181 +++++- .../src/domain/repositories/job.repository.ts | 2 +- .../domain/repositories/storage.repository.ts | 4 + .../dto/system-config-library.dto.ts | 21 + .../system-config/system-config.core.ts | 5 + .../system-config.service.spec.ts | 5 + server/src/immich/app.service.ts | 4 + .../infra/entities/system-config.entity.ts | 12 + .../repositories/filesystem.provider.spec.ts | 6 +- .../infra/repositories/filesystem.provider.ts | 6 + server/src/microservices/app.service.ts | 4 +- server/src/test-utils/utils.ts | 32 +- server/test/fixtures/library.stub.ts | 70 +++ server/test/fixtures/system-config.stub.ts | 2 + server/test/mocks/fswatcher.mock.ts | 24 + server/test/mocks/index.ts | 1 + .../repositories/storage.repository.mock.ts | 1 + .../library-settings/library-settings.svelte | 48 +- 48 files changed, 1400 insertions(+), 109 deletions(-) create mode 100644 mobile/openapi/doc/SystemConfigLibraryWatchDto.md create mode 100644 mobile/openapi/lib/model/system_config_library_watch_dto.dart create mode 100644 mobile/openapi/test/system_config_library_watch_dto_test.dart create mode 100644 server/e2e/jobs/config/library-watcher-e2e-config.json create mode 100644 server/e2e/jobs/specs/library-watcher.e2e-spec.ts create mode 100644 server/test/mocks/fswatcher.mock.ts create mode 100644 server/test/mocks/index.ts diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 6f14c456dd..6dcb9982bf 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -72,7 +72,7 @@ For security purposes, each Immich user is disallowed to add external files by d With the `external path` set, a user is restricted to accessing external files to files or directories within that path. The Immich admin should still be careful not set the external path too generously. For example, `user1` wants to read their photos in to `/home/user1`. A lazy admin sets that user's external path to `/home/` since it "gets the job done". However, that user will then be able to read all photos in `/home/user2/private-photos`, too! Please set the external path as specific as possible. If multiple folders must be added, do this using the docker volume mount feature described below. -### Exclusion Patterns and Scan Settings +### Exclusion Patterns By default, all files in the import paths will be added to the library. If there are files that should not be added, exclusion patterns can be used to exclude them. Exclusion patterns are glob patterns are matched against the full file path. If a file matches an exclusion pattern, it will not be added to the library. Exclusion patterns can be added in the Scan Settings page for each library. Under the hood, Immich uses the [glob](https://www.npmjs.com/package/glob) package to match patterns, so please refer to [their documentation](https://github.com/isaacs/node-glob#glob-primer) to see what patterns are supported. @@ -83,6 +83,15 @@ Some basic examples: - `**/Raw/**` will exclude all files in any directory named `Raw` - `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg` +### Automatic watching (EXPERIMENTAL) + +This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button. + +If your photos are on a network drive you will likely have to enable filesystem polling. The performance hit for polling large libraries is currently unknown, feel free to test this feature and report back. In addition to the boolean feature flag, the configuration file allows customization of the following parameters, please see the [chokidar documentation](https://github.com/paulmillr/chokidar?tab=readme-ov-file#performance) for reference. + +- `usePolling` (default: `false`). +- `interval`. (default: 10000). When using polling, this is how often (in milliseconds) the filesystem is polled. + ### Nightly job There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion. diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index be403d8ff7..755722d9e7 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -129,6 +129,11 @@ The default configuration looks like this: "scan": { "enabled": true, "cronExpression": "0 0 * * *" + }, + "watch": { + "enabled": false, + "usePolling": false, + "interval": 10000 } } } diff --git a/docs/src/pages/milestones.tsx b/docs/src/pages/milestones.tsx index def90f0fa7..dd4adb0b93 100644 --- a/docs/src/pages/milestones.tsx +++ b/docs/src/pages/milestones.tsx @@ -1,4 +1,5 @@ import { + mdiEyeRefreshOutline, mdiAccountGroup, mdiAndroid, mdiAppleIos, @@ -54,6 +55,15 @@ import React from 'react'; import Timeline, { DateType, Item } from '../components/timeline'; const items: Item[] = [ + { + icon: mdiEyeRefreshOutline, + description: 'Automatically import files in external libraries when the operating system detects changes.', + title: 'Library watching', + release: 'v1.94.0', + tag: 'v1.94.0', + date: new Date(2024, 1, 31), + dateType: DateType.RELEASE, + }, { icon: mdiMatrix, description: 'Moved the search from typesense to pgvecto.rs', diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 3abf82a93d..6fc968a491 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -143,6 +143,7 @@ doc/SystemConfigFFmpegDto.md doc/SystemConfigJobDto.md doc/SystemConfigLibraryDto.md doc/SystemConfigLibraryScanDto.md +doc/SystemConfigLibraryWatchDto.md doc/SystemConfigLoggingDto.md doc/SystemConfigMachineLearningDto.md doc/SystemConfigMapDto.md @@ -333,6 +334,7 @@ lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_job_dto.dart lib/model/system_config_library_dto.dart lib/model/system_config_library_scan_dto.dart +lib/model/system_config_library_watch_dto.dart lib/model/system_config_logging_dto.dart lib/model/system_config_machine_learning_dto.dart lib/model/system_config_map_dto.dart @@ -508,6 +510,7 @@ test/system_config_f_fmpeg_dto_test.dart test/system_config_job_dto_test.dart test/system_config_library_dto_test.dart test/system_config_library_scan_dto_test.dart +test/system_config_library_watch_dto_test.dart test/system_config_logging_dto_test.dart test/system_config_machine_learning_dto_test.dart test/system_config_map_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 97a04aa376cfe9e5604e05549f4989572eea48cc..87d0bff1ea5ff11b61ed681be35c16aa204a227d 100644 GIT binary patch delta 39 tcmZ2~i1F(o#tpk8xHFTA5{oLs6HAgaCObyRvVd5VA4W=U-W5^G0RUQn5Pbju delta 18 acmex*h;jWP#tpk8Cd)-~Zsv;2<^TXyF$anO diff --git a/mobile/openapi/doc/CreateLibraryDto.md b/mobile/openapi/doc/CreateLibraryDto.md index c2ccf9bf1e43712da217bc922a66d58f8eb74f99..9e4859cee9f0ef884b512e3e701343740c808012 100644 GIT binary patch delta 22 dcmbQmdW3buTt?2!;_$?h%S^kgN*%>bLK9gYA1 delta 11 ScmZ3CBvWC*k2l#CTt(1alK1A# zyv!>&dT`2{vPTWJd>$e}|A6jjy0tk36^2(u?WrPUtSI;zmF9E;KFYaQ?VIX=HBF;c zAZB`xf%xpo-(H6=vOyNi?y_{8REJayWFWGw>Vbdom%-<&g4hP*$)HPUi#y=s|z*4N%0(^Mr)1hersmC-F-@c6Y@=tk6ROI zzrQKV#nWn8ZB}Kk3ytl$2S+MzB##*6m|dLnukL02_|M*yqy(ourf+{6aWVX1K4*-5 E0&M}5QUCw| literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index fbe74168a3d98047bc28220fdbf21e234cc12f31..8b9a320566443558a86babea1f9d6595d6105eb6 100644 GIT binary patch delta 26 icmX@?@XcYv1c}LU66~zyi6zMylOKw!ZSI%Y!VUnH`U->q delta 12 Ucmez7aM)qP1c}XuB{s7I04j0?+5i9m diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index deb111c6a841c660d004293343f155826652bce4..a305ded56a9f193ab45ef5393bdabf220b7980d7 100644 GIT binary patch delta 47 zcmeykjq&z&#tl-g+?h#5iA9y+i6zMylNUNGPrmQM&I001R`gPt-0#e@S<*E^3;@z# B5z+tv delta 18 acmcb;o$=!~#tl-glh?U&ZtivU69WKK#|Ogz diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 078154812150834f7524630460b044935eeda152..ca4217dcfa03970eb470b3a6963c0d0830616e81 100644 GIT binary patch delta 219 zcmX@1_FH?yQ%26r;_$?h2)8ROz$d6oIR`%vvI^DSwHzThqM&bDeAEbwhAQ~nZ}7zJ3%6HAgaCa+-BpS+n-m@gc}b1BJJfCyh=Y?Dz?Q{z$qg8Y(< z)FM5wxPq-MR6zt&JgpAJOURs|yTpQ%VzK_RO+KQFpS9W10Ct6-~8 zl95>qF>iA=^C3nK1&A)I$&M@{a^X;i=%p3q=XwFPY9Q<1tjKE2Caj-8MP;iF*Q!!%oIOali6c(J#*1!5tcoSo5k4F+1P5WxoWw%0B*_+ A_y7O^ diff --git a/mobile/openapi/lib/model/system_config_library_watch_dto.dart b/mobile/openapi/lib/model/system_config_library_watch_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..795fd15fd33b9d79a5fd7fb00bf133ddad563d9b GIT binary patch literal 3503 zcmbVOVQ8-5uZEdw1ku&>IZk%^#ETi{D1~qmOSUqieW%cRLEdcl;5Uq(N_?9r=OmQwK~lPbwX!7pJ`ZZuycyx=Qdn$W+C)mkcZ(t{OSw$sUa zV-m&x-3W#5C0pU&HB1TK%s{Sr$<`vm->Y6PEtuBuu+xUG-pVo)%Udy5Ozr++Ce5#ngvTDh z0BiA)n~f?pFev#JrX4Ob5EgXGBlPVs3@A2$uM3!5=m^ox3%&?Xd=e#A>oY4(eM4S8 z$N~$nq_6u6%ne{JWdT({yMPf8oQu*}G`@E2wfxML!h-tal}SV)s| zbpi1Z95E>?0)ijIYUBCbaUYKw4!(QnWKcgJSTw1SP4jk?7*%4) z4dhJcZ{>nxDi%nsdJ=odQ?50tfy9dX%efZXngvx=Bkz9!&)o-)>-bB9rulY+>%l1G z5R7yWfvs`BLqyXGNqp{=QWHs6{{v zduo9nZCzmH0^D5FAdCj=yA9WnDY>e`PVEwQdxi#mnhaAcC7P}k|{cIOyF>2+7^g}zL#dSTAQ5@ zkG$g)52{_{7OQPdo9|v3PBn2C+Yz;b%@Nb7s%IIjMRyk4R%l13(~gph*ka+N+46L? z_Z%EBQF$3j83w+9m*Agl6x;9>P3@7^$rdW~Bv~G{D;8R{$6i-NKMLEtAev5EEPt`1 z?OT0_qpZ4+T|#|#O7QxEw=}wqxr6C|Vq8@_Pcojac(!ou6CDR47$?^*{UDXz2OhvZ9ECv_CU)0!S*v=#RKU4KMpPn6J8-a3Qqh5f3B)9XKh CEPp8g literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/create_library_dto_test.dart b/mobile/openapi/test/create_library_dto_test.dart index eecccbcf759cf825587b878bbbfedf400eddac43..dea1d4e631c05f6a9fc0186d8856ff7d19cd7680 100644 GIT binary patch delta 39 ncmbQs(ZsnSnwc*tKR-tyvp77lBsn8BW%67mB@Q@8kNG_S49*Sr delta 11 ScmZqToXfExntAd8=0gA&6$DoR diff --git a/mobile/openapi/test/system_config_library_dto_test.dart b/mobile/openapi/test/system_config_library_dto_test.dart index f7051c82eda2c8bbd86daaddb09eb57a8f478574..6b24124591d2928d3681fac58ac1f6042ca9600a 100644 GIT binary patch delta 40 pcmcb`a*lOF0Mq3Aj8c5zi6zMyE+zR2{ngd~M&I)mFZxxRefV_1hg%hCGr@xjq?)Qz&# z#*!$E6@^rE3XN_Y<$@Gdr2TL+%c?fkZ)Ko*$JU9u@xsz;BQ4W4R=n3}`SfHRi(9vy zo*C^tYAw literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7c8bff2cda..61d878dc13 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7601,6 +7601,9 @@ "isVisible": { "type": "boolean" }, + "isWatched": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -9531,10 +9534,14 @@ "properties": { "scan": { "$ref": "#/components/schemas/SystemConfigLibraryScanDto" + }, + "watch": { + "$ref": "#/components/schemas/SystemConfigLibraryWatchDto" } }, "required": [ - "scan" + "scan", + "watch" ], "type": "object" }, @@ -9553,6 +9560,25 @@ ], "type": "object" }, + "SystemConfigLibraryWatchDto": { + "properties": { + "enabled": { + "type": "boolean" + }, + "interval": { + "type": "integer" + }, + "usePolling": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "interval", + "usePolling" + ], + "type": "object" + }, "SystemConfigLoggingDto": { "properties": { "enabled": { diff --git a/open-api/typescript-sdk/client/api.ts b/open-api/typescript-sdk/client/api.ts index 967e24cf1e..04fff26ba6 100644 --- a/open-api/typescript-sdk/client/api.ts +++ b/open-api/typescript-sdk/client/api.ts @@ -1387,6 +1387,12 @@ export interface CreateLibraryDto { * @memberof CreateLibraryDto */ 'isVisible'?: boolean; + /** + * + * @type {boolean} + * @memberof CreateLibraryDto + */ + 'isWatched'?: boolean; /** * * @type {string} @@ -3860,6 +3866,12 @@ export interface SystemConfigLibraryDto { * @memberof SystemConfigLibraryDto */ 'scan': SystemConfigLibraryScanDto; + /** + * + * @type {SystemConfigLibraryWatchDto} + * @memberof SystemConfigLibraryDto + */ + 'watch': SystemConfigLibraryWatchDto; } /** * @@ -3880,6 +3892,31 @@ export interface SystemConfigLibraryScanDto { */ 'enabled': boolean; } +/** + * + * @export + * @interface SystemConfigLibraryWatchDto + */ +export interface SystemConfigLibraryWatchDto { + /** + * + * @type {boolean} + * @memberof SystemConfigLibraryWatchDto + */ + 'enabled': boolean; + /** + * + * @type {number} + * @memberof SystemConfigLibraryWatchDto + */ + 'interval': number; + /** + * + * @type {boolean} + * @memberof SystemConfigLibraryWatchDto + */ + 'usePolling': boolean; +} /** * * @export diff --git a/server/e2e/api/jest-e2e.json b/server/e2e/api/jest-e2e.json index a5d03b438f..9fd67774f3 100644 --- a/server/e2e/api/jest-e2e.json +++ b/server/e2e/api/jest-e2e.json @@ -5,7 +5,6 @@ "globalSetup": "/e2e/api/setup.ts", "testEnvironment": "node", "testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"], - "testTimeout": 60000, "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/server/e2e/client/library-api.ts b/server/e2e/client/library-api.ts index d70e7bd623..c40f005457 100644 --- a/server/e2e/client/library-api.ts +++ b/server/e2e/client/library-api.ts @@ -1,4 +1,10 @@ -import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, ScanLibraryDto } from '@app/domain'; +import { + CreateLibraryDto, + LibraryResponseDto, + LibraryStatsResponseDto, + ScanLibraryDto, + UpdateLibraryDto, +} from '@app/domain'; import request from 'supertest'; export const libraryApi = { @@ -44,4 +50,12 @@ export const libraryApi = { expect(status).toBe(200); return body; }, + update: async (server: any, accessToken: string, id: string, data: UpdateLibraryDto) => { + const { body, status } = await request(server) + .put(`/library/${id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send(data); + expect(status).toBe(200); + return body as LibraryResponseDto; + }, }; diff --git a/server/e2e/jobs/config/library-watcher-e2e-config.json b/server/e2e/jobs/config/library-watcher-e2e-config.json new file mode 100644 index 0000000000..9f7420ca5a --- /dev/null +++ b/server/e2e/jobs/config/library-watcher-e2e-config.json @@ -0,0 +1,17 @@ +{ + "reverseGeocoding": { + "enabled": false + }, + "machineLearning": { + "enabled": false + }, + "logging": { + "enabled": false, + "level": "debug" + }, + "library": { + "watch": { + "enabled": true + } + } +} diff --git a/server/e2e/jobs/immich-e2e-config.json b/server/e2e/jobs/immich-e2e-config.json index 39fbf9c24f..4f018dc164 100644 --- a/server/e2e/jobs/immich-e2e-config.json +++ b/server/e2e/jobs/immich-e2e-config.json @@ -6,6 +6,7 @@ "enabled": false }, "logging": { - "enabled": false + "enabled": false, + "level": "debug" } } diff --git a/server/e2e/jobs/jest-e2e.json b/server/e2e/jobs/jest-e2e.json index c7ebc60e0e..333174c5a9 100644 --- a/server/e2e/jobs/jest-e2e.json +++ b/server/e2e/jobs/jest-e2e.json @@ -5,7 +5,7 @@ "globalSetup": "/e2e/jobs/setup.ts", "testEnvironment": "node", "testMatch": ["**/e2e/jobs/specs/*.e2e-spec.[tj]s"], - "testTimeout": 60000, + "testTimeout": 10000, "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts new file mode 100644 index 0000000000..f9c50a411f --- /dev/null +++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts @@ -0,0 +1,235 @@ +import { LibraryResponseDto, LibraryService, LoginResponseDto } from '@app/domain'; +import { AssetType, LibraryType } from '@app/infra/entities'; +import * as fs from 'fs/promises'; +import { api } from '../../client'; + +import path from 'path'; +import { + IMMICH_TEST_ASSET_PATH, + IMMICH_TEST_ASSET_TEMP_PATH, + restoreTempFolder, + testApp, + waitForEvent, +} from '../../../src/test-utils/utils'; + +describe(`Library watcher (e2e)`, () => { + let server: any; + let admin: LoginResponseDto; + let libraryService: LibraryService; + const configFilePath = process.env.IMMICH_CONFIG_FILE; + + beforeAll(async () => { + process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../config/library-watcher-e2e-config.json`); + + server = (await testApp.create()).getHttpServer(); + libraryService = testApp.get(LibraryService); + }); + + beforeEach(async () => { + await testApp.reset(); + await restoreTempFolder(); + await api.authApi.adminSignUp(server); + admin = await api.authApi.adminLogin(server); + + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + }); + + afterEach(async () => { + await libraryService.unwatchAll(); + }); + + afterAll(async () => { + await testApp.teardown(); + await restoreTempFolder(); + process.env.IMMICH_CONFIG_FILE = configFilePath; + }); + + describe('Event handling', () => { + let library: LibraryResponseDto; + + describe('Single import path', () => { + beforeEach(async () => { + library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + }); + + it('should import a new file', async () => { + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`, + ); + + await waitForEvent(libraryService, 'add'); + + const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(afterAssets.length).toEqual(1); + }); + + it('should import new files with case insensitive extensions', async () => { + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/file2.JPG`, + ); + + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/file3.Jpg`, + ); + + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/file4.jpG`, + ); + + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/file5.jPg`, + ); + + await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, 'add'); + + const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(afterAssets.length).toEqual(4); + }); + + it('should update a changed file', async () => { + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`, + ); + + await waitForEvent(libraryService, 'add'); + + const originalAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(originalAssets.length).toEqual(1); + + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/prairie_falcon.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`, + ); + + await waitForEvent(libraryService, 'change'); + + const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(afterAssets).toEqual([ + expect.objectContaining({ + // Make sure we keep the original asset id + id: originalAssets[0].id, + type: AssetType.IMAGE, + exifInfo: expect.objectContaining({ + make: 'Canon', + model: 'Canon EOS R5', + exifImageWidth: 800, + exifImageHeight: 533, + exposureTime: '1/4000', + }), + }), + ]); + }); + }); + + describe('Multiple import paths', () => { + beforeEach(async () => { + await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, { recursive: true }); + await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, { recursive: true }); + await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true }); + + library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [ + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, + ], + }); + }); + + it('should add new files in multiple import paths', async () => { + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file2.jpg`, + ); + + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/polemonium_reptans.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir2/file3.jpg`, + ); + + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/tanners_ridge.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir3/file4.jpg`, + ); + + await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, 'add'); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(assets.length).toEqual(3); + }); + + it('should offline a removed file', async () => { + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/polemonium_reptans.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file.jpg`, + ); + + await waitForEvent(libraryService, 'add'); + + const addedAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(addedAssets.length).toEqual(1); + + await fs.unlink(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file.jpg`); + + await waitForEvent(libraryService, 'unlink'); + + const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(afterAssets[0].isOffline).toEqual(true); + }); + }); + }); + + describe('Configuration', () => { + let library: LibraryResponseDto; + + beforeEach(async () => { + library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [ + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, + ], + }); + + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, { recursive: true }); + await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, { recursive: true }); + await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true }); + }); + + it('should use an updated import paths', async () => { + await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir4`, { recursive: true }); + + await api.libraryApi.setImportPaths(server, admin.accessToken, library.id, [ + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir4`, + ]); + + await fs.copyFile( + `${IMMICH_TEST_ASSET_PATH}/albums/nature/polemonium_reptans.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/dir4/file.jpg`, + ); + + await waitForEvent(libraryService, 'add'); + + const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); + expect(afterAssets.length).toEqual(1); + }); + }); +}); diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index da7fcc0fdc..d5fefa701b 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -21,11 +21,6 @@ describe(`${LibraryController.name} (e2e)`, () => { server = (await testApp.create()).getHttpServer(); }); - afterAll(async () => { - await testApp.teardown(); - await restoreTempFolder(); - }); - beforeEach(async () => { await testApp.reset(); await restoreTempFolder(); @@ -33,6 +28,11 @@ describe(`${LibraryController.name} (e2e)`, () => { admin = await api.authApi.adminLogin(server); }); + afterAll(async () => { + await testApp.teardown(); + await restoreTempFolder(); + }); + describe('DELETE /library/:id', () => { it('should delete an external library with assets', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { diff --git a/server/e2e/jobs/specs/metadata.e2e-spec.ts b/server/e2e/jobs/specs/metadata.e2e-spec.ts index cbd26b5580..5eb75fee2d 100644 --- a/server/e2e/jobs/specs/metadata.e2e-spec.ts +++ b/server/e2e/jobs/specs/metadata.e2e-spec.ts @@ -1,6 +1,5 @@ import { AssetResponseDto, LoginResponseDto } from '@app/domain'; import { AssetController } from '@app/immich'; -import { INestApplication } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; import { readFile, writeFile } from 'fs/promises'; import { @@ -13,17 +12,15 @@ import { import { api } from '../../client'; describe(`${AssetController.name} (e2e)`, () => { - let app: INestApplication; let server: any; let admin: LoginResponseDto; beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); + server = (await testApp.create()).getHttpServer(); }); beforeEach(async () => { - await db.reset(); + await testApp.reset(); await restoreTempFolder(); await api.authApi.adminSignUp(server); admin = await api.authApi.adminLogin(server); diff --git a/server/package-lock.json b/server/package-lock.json index 2657fc6d49..e6e5a5aaf0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -22,6 +22,7 @@ "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", "@socket.io/postgres-adapter": "^0.3.1", + "@types/picomatch": "^2.3.3", "archiver": "^6.0.0", "async-lock": "^1.4.0", "axios": "^1.5.0", @@ -45,6 +46,7 @@ "node-addon-api": "^7.0.0", "openid-client": "^5.4.3", "pg": "^8.11.3", + "picomatch": "^3.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", @@ -76,6 +78,7 @@ "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", + "chokidar": "^3.5.3", "dotenv": "^16.3.1", "eslint": "^8.48.0", "eslint-config-prettier": "^9.0.0", @@ -147,18 +150,6 @@ } } }, - "node_modules/@angular-devkit/core/node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@angular-devkit/schematics": { "version": "17.0.9", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.0.9.tgz", @@ -3304,6 +3295,11 @@ "node": ">=12" } }, + "node_modules/@types/picomatch": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.3.tgz", + "integrity": "sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==" + }, "node_modules/@types/qs": { "version": "6.9.8", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.8.tgz", @@ -3994,6 +3990,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/app-root-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", @@ -7924,6 +7932,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -8417,6 +8437,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -9287,12 +9319,11 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", "engines": { - "node": ">=8.6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -9768,6 +9799,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -12248,14 +12291,6 @@ "picomatch": "3.0.1", "rxjs": "7.8.1", "source-map": "0.7.4" - }, - "dependencies": { - "picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", - "dev": true - } } }, "@angular-devkit/schematics": { @@ -14468,6 +14503,11 @@ } } }, + "@types/picomatch": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.3.tgz", + "integrity": "sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==" + }, "@types/qs": { "version": "6.9.8", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.8.tgz", @@ -15007,6 +15047,14 @@ "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" + }, + "dependencies": { + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + } } }, "app-root-path": { @@ -17938,6 +17986,14 @@ "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" + }, + "dependencies": { + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + } } }, "jest-validate": { @@ -18339,6 +18395,14 @@ "requires": { "braces": "^3.0.2", "picomatch": "^2.3.1" + }, + "dependencies": { + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + } } }, "mime": { @@ -19005,10 +19069,9 @@ "dev": true }, "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==" }, "pirates": { "version": "4.0.6", @@ -19345,6 +19408,14 @@ "dev": true, "requires": { "picomatch": "^2.2.1" + }, + "dependencies": { + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + } } }, "rechoir": { diff --git a/server/package.json b/server/package.json index f9aaf11df0..e93a8f9d9b 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", "@socket.io/postgres-adapter": "^0.3.1", + "@types/picomatch": "^2.3.3", "archiver": "^6.0.0", "async-lock": "^1.4.0", "axios": "^1.5.0", @@ -70,6 +71,7 @@ "node-addon-api": "^7.0.0", "openid-client": "^5.4.3", "pg": "^8.11.3", + "picomatch": "^3.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", @@ -101,6 +103,7 @@ "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", + "chokidar": "^3.5.3", "dotenv": "^16.3.1", "eslint": "^8.48.0", "eslint-config-prettier": "^9.0.0", diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts index a3f3078700..e12ca4c185 100644 --- a/server/src/domain/library/library.dto.ts +++ b/server/src/domain/library/library.dto.ts @@ -26,6 +26,10 @@ export class CreateLibraryDto { @IsString({ each: true }) @IsNotEmpty({ each: true }) exclusionPatterns?: string[]; + + @IsOptional() + @IsBoolean() + isWatched?: boolean; } export class UpdateLibraryDto { diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index e52d4cdc4b..4117f4129e 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -2,9 +2,9 @@ import { AssetType, LibraryType, SystemConfig, SystemConfigKey, UserEntity } fro import { BadRequestException } from '@nestjs/common'; import { + IAccessRepositoryMock, assetStub, authStub, - IAccessRepositoryMock, libraryStub, newAccessRepositoryMock, newAssetRepositoryMock, @@ -14,8 +14,11 @@ import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, + systemConfigStub, userStub, } from '@test'; + +import { newFSWatcherMock } from '@test/mocks'; import { Stats } from 'fs'; import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job'; import { @@ -28,6 +31,7 @@ import { IUserRepository, } from '../repositories'; import { SystemConfigCore } from '../system-config/system-config.core'; +import { mapLibrary } from './library.dto'; import { LibraryService } from './library.service'; describe(LibraryService.name, () => { @@ -94,11 +98,60 @@ describe(LibraryService.name, () => { enabled: true, cronExpression: '0 1 * * *', }, + watch: { enabled: false }, }, } as SystemConfig); expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); }); + + it('should initialize watcher for all external libraries', async () => { + libraryMock.getAll.mockResolvedValue([ + libraryStub.externalLibraryWithImportPaths1, + libraryStub.externalLibraryWithImportPaths2, + ]); + + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + libraryMock.get.mockImplementation(async (id) => { + switch (id) { + case libraryStub.externalLibraryWithImportPaths1.id: + return libraryStub.externalLibraryWithImportPaths1; + case libraryStub.externalLibraryWithImportPaths2.id: + return libraryStub.externalLibraryWithImportPaths2; + default: + return null; + } + }); + + const mockWatcher = newFSWatcherMock(); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } + }); + + storageMock.watch.mockReturnValue(mockWatcher); + + await sut.init(); + + expect(storageMock.watch.mock.calls).toEqual( + expect.arrayContaining([ + (libraryStub.externalLibrary1.importPaths, expect.anything()), + (libraryStub.externalLibrary2.importPaths, expect.anything()), + ]), + ); + }); + + it('should not initialize when watching is disabled', async () => { + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); + + await sut.init(); + + expect(storageMock.watch).not.toHaveBeenCalled(); + }); }); describe('handleQueueAssetRefresh', () => { @@ -148,6 +201,34 @@ describe(LibraryService.name, () => { ]); }); + it('should force queue new assets', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: true, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); + assetMock.getByLibraryId.mockResolvedValue([]); + libraryMock.getOnlineAssetPaths.mockResolvedValue([]); + userMock.get.mockResolvedValue(userStub.externalPath1); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryStub.externalLibrary1.id, + ownerId: libraryStub.externalLibrary1.owner.id, + assetPath: '/data/user1/photo.jpg', + force: true, + }, + }, + ]); + }); + it("should mark assets outside of the user's external path as offline", async () => { const mockLibraryJob: ILibraryRefreshJob = { id: libraryStub.externalLibrary1.id, @@ -564,7 +645,7 @@ describe(LibraryService.name, () => { expect(createdAsset.fileModifiedAt).toEqual(filemtime); }); - it('should error when asset does not exist', async () => { + it('should throw error when asset does not exist', async () => { storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'")); const mockLibraryJob: ILibraryFileJob = { @@ -625,6 +706,31 @@ describe(LibraryService.name, () => { expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); + + it('should unwatch an external library when deleted', async () => { + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + libraryMock.getUploadLibraryCount.mockResolvedValue(1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + + const mockWatcher = newFSWatcherMock(); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } + }); + + storageMock.watch.mockReturnValue(mockWatcher); + + await sut.init(); + + await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id); + + expect(mockWatcher.close).toHaveBeenCalled(); + }); }); describe('getCount', () => { @@ -638,7 +744,7 @@ describe(LibraryService.name, () => { }); describe('get', () => { - it('can return a library', async () => { + it('should return a library', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual( expect.objectContaining({ @@ -659,7 +765,7 @@ describe(LibraryService.name, () => { }); describe('getAllForUser', () => { - it('can return all libraries for user', async () => { + it('should return all libraries for user', async () => { libraryMock.getAllByUserId.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); await expect(sut.getAllForUser(authStub.admin)).resolves.toEqual([ expect.objectContaining({ @@ -679,7 +785,7 @@ describe(LibraryService.name, () => { }); describe('getStatistics', () => { - it('can return library statistics', async () => { + it('should return library statistics', async () => { libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({ photos: 10, @@ -694,7 +800,7 @@ describe(LibraryService.name, () => { describe('create', () => { describe('external library', () => { - it('can create with default settings', async () => { + it('should create with default settings', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create(authStub.admin, { @@ -717,7 +823,7 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ - name: 'New External Library', + name: expect.any(String), type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: [], @@ -726,7 +832,7 @@ describe(LibraryService.name, () => { ); }); - it('can create with name', async () => { + it('should create with name', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create(authStub.admin, { @@ -759,7 +865,7 @@ describe(LibraryService.name, () => { ); }); - it('can create invisible', async () => { + it('should create invisible', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create(authStub.admin, { @@ -783,7 +889,7 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ - name: 'New External Library', + name: expect.any(String), type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: [], @@ -792,7 +898,7 @@ describe(LibraryService.name, () => { ); }); - it('can create with import paths', async () => { + it('should create with import paths', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create(authStub.admin, { @@ -816,7 +922,7 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ - name: 'New External Library', + name: expect.any(String), type: LibraryType.EXTERNAL, importPaths: ['/data/images', '/data/videos'], exclusionPatterns: [], @@ -825,7 +931,35 @@ describe(LibraryService.name, () => { ); }); - it('can create with exclusion patterns', async () => { + it('should create watched with import paths', async () => { + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([]); + + const mockWatcher = newFSWatcherMock(); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } + }); + + storageMock.watch.mockReturnValue(mockWatcher); + + await sut.init(); + await sut.create(authStub.admin, { + type: LibraryType.EXTERNAL, + importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, + }); + + expect(storageMock.watch).toHaveBeenCalledWith( + libraryStub.externalLibraryWithImportPaths1.importPaths, + expect.anything(), + ); + }); + + it('should create with exclusion patterns', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( sut.create(authStub.admin, { @@ -849,7 +983,7 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ - name: 'New External Library', + name: expect.any(String), type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: ['*.tmp', '*.bak'], @@ -860,7 +994,7 @@ describe(LibraryService.name, () => { }); describe('upload library', () => { - it('can create with default settings', async () => { + it('should create with default settings', async () => { libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); await expect( sut.create(authStub.admin, { @@ -892,7 +1026,7 @@ describe(LibraryService.name, () => { ); }); - it('can create with name', async () => { + it('should create with name', async () => { libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); await expect( sut.create(authStub.admin, { @@ -925,7 +1059,7 @@ describe(LibraryService.name, () => { ); }); - it('can not create with import paths', async () => { + it('should not create with import paths', async () => { await expect( sut.create(authStub.admin, { type: LibraryType.UPLOAD, @@ -936,7 +1070,7 @@ describe(LibraryService.name, () => { expect(libraryMock.create).not.toHaveBeenCalled(); }); - it('can not create with exclusion patterns', async () => { + it('should not create with exclusion patterns', async () => { await expect( sut.create(authStub.admin, { type: LibraryType.UPLOAD, @@ -946,11 +1080,22 @@ describe(LibraryService.name, () => { expect(libraryMock.create).not.toHaveBeenCalled(); }); + + it('should not create watched', async () => { + await expect( + sut.create(authStub.admin, { + type: LibraryType.UPLOAD, + isWatched: true, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(storageMock.watch).not.toHaveBeenCalled(); + }); }); }); describe('handleQueueCleanup', () => { - it('can queue cleanup jobs', async () => { + it('should queue cleanup jobs', async () => { libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); await expect(sut.handleQueueCleanup()).resolves.toBe(true); @@ -962,19 +1107,357 @@ describe(LibraryService.name, () => { }); describe('update', () => { - it('can update library ', async () => { + beforeEach(async () => { + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + libraryMock.getAll.mockResolvedValue([]); + + await sut.init(); + }); + + it('should update library', async () => { libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toBeTruthy(); + await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toEqual( + mapLibrary(libraryStub.uploadLibrary1), + ); expect(libraryMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: authStub.admin.user.id, }), ); }); + + it('should re-watch library when updating import paths', async () => { + libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + + const mockWatcher = newFSWatcherMock(); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } + }); + + storageMock.watch.mockReturnValue(mockWatcher); + + await expect(sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/foo'] })).resolves.toEqual( + mapLibrary(libraryStub.externalLibraryWithImportPaths1), + ); + + expect(libraryMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: authStub.admin.user.id, + }), + ); + expect(storageMock.watch).toHaveBeenCalledWith( + libraryStub.externalLibraryWithImportPaths1.importPaths, + expect.anything(), + ); + }); + + it('should re-watch library when updating exclusion patterns', async () => { + libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + + const mockWatcher = newFSWatcherMock(); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } + }); + + storageMock.watch.mockReturnValue(mockWatcher); + + await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual( + mapLibrary(libraryStub.externalLibraryWithImportPaths1), + ); + + expect(libraryMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: authStub.admin.user.id, + }), + ); + expect(storageMock.watch).toHaveBeenCalledWith(expect.arrayContaining([expect.any(String)]), expect.anything()); + }); + }); + + describe('watchAll new', () => { + describe('watching disabled', () => { + beforeEach(async () => { + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); + + await sut.init(); + }); + + it('should not watch library', async () => { + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + await sut.watchAll(); + + expect(storageMock.watch).not.toHaveBeenCalled(); + }); + }); + + describe('watching enabled', () => { + const mockWatcher = newFSWatcherMock(); + beforeEach(async () => { + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + libraryMock.getAll.mockResolvedValue([]); + await sut.init(); + + storageMock.watch.mockReturnValue(mockWatcher); + }); + + it('should watch library', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + const mockWatcher = newFSWatcherMock(); + + let isReady = false; + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + isReady = true; + callback(); + } + }); + + storageMock.watch.mockReturnValue(mockWatcher); + + await sut.watchAll(); + + expect(storageMock.watch).toHaveBeenCalledWith( + libraryStub.externalLibraryWithImportPaths1.importPaths, + expect.anything(), + ); + + expect(isReady).toBe(true); + }); + + it('should watch and unwatch library', async () => { + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } + }); + + await sut.watchAll(); + await sut.unwatch(libraryStub.externalLibraryWithImportPaths1.id); + + expect(mockWatcher.close).toHaveBeenCalled(); + }); + + it('should not watch library without import paths', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + + await sut.watchAll(); + + expect(storageMock.watch).not.toHaveBeenCalled(); + }); + + it('should throw error when watching upload library', async () => { + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + libraryMock.getAll.mockResolvedValue([libraryStub.uploadLibrary1]); + + await expect(sut.watchAll()).rejects.toThrow('Can only watch external libraries'); + + expect(storageMock.watch).not.toHaveBeenCalled(); + }); + + it('should handle a new file event', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } else if (event === 'add') { + callback('/foo/photo.jpg'); + } + }); + + await sut.watchAll(); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryStub.externalLibraryWithImportPaths1.id, + assetPath: '/foo/photo.jpg', + ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, + force: false, + }, + }, + ]); + }); + + it('should handle a file change event', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } else if (event === 'change') { + callback('/foo/photo.jpg'); + } + }); + + await sut.watchAll(); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryStub.externalLibraryWithImportPaths1.id, + assetPath: '/foo/photo.jpg', + ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, + force: false, + }, + }, + ]); + }); + + it('should handle a file unlink event', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } else if (event === 'unlink') { + callback('/foo/photo.jpg'); + } + }); + + await sut.watchAll(); + + expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + }); + + it('should handle an error event', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + let didError = false; + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } else if (event === 'error') { + didError = true; + callback('Error!'); + } + }); + + await sut.watchAll(); + + expect(didError).toBe(true); + }); + + it('should ignore unknown extensions', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } else if (event === 'add') { + callback('/foo/photo.txt'); + } + }); + + await sut.watchAll(); + + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should ignore excluded paths', async () => { + libraryMock.get.mockResolvedValue(libraryStub.patternPath); + libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } else if (event === 'add') { + callback('/dir1/photo.txt'); + } + }); + + await sut.watchAll(); + + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should ignore excluded paths without case sensitivity', async () => { + libraryMock.get.mockResolvedValue(libraryStub.patternPath); + libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } else if (event === 'add') { + callback('/DIR1/photo.txt'); + } + }); + + await sut.watchAll(); + + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + }); + }); + + describe('tearDown', () => { + it('should tear down all watchers', async () => { + libraryMock.getAll.mockResolvedValue([ + libraryStub.externalLibraryWithImportPaths1, + libraryStub.externalLibraryWithImportPaths2, + ]); + + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + libraryMock.get.mockImplementation(async (id) => { + switch (id) { + case libraryStub.externalLibraryWithImportPaths1.id: + return libraryStub.externalLibraryWithImportPaths1; + case libraryStub.externalLibraryWithImportPaths2.id: + return libraryStub.externalLibraryWithImportPaths2; + default: + return null; + } + }); + + const mockWatcher = newFSWatcherMock(); + + mockWatcher.on.mockImplementation((event, callback) => { + if (event === 'ready') { + callback(); + } + }); + + storageMock.watch.mockReturnValue(mockWatcher); + + await sut.init(); + await sut.unwatchAll(); + + expect(mockWatcher.close).toHaveBeenCalledTimes(2); + }); }); describe('handleDeleteLibrary', () => { - it('can not delete a nonexistent library', async () => { + it('should not delete a nonexistent library', async () => { libraryMock.get.mockImplementation(async () => { return null; }); @@ -984,7 +1467,7 @@ describe(LibraryService.name, () => { await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(false); }); - it('can delete an empty library', async () => { + it('should delete an empty library', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.delete.mockImplementation(async () => {}); @@ -992,7 +1475,7 @@ describe(LibraryService.name, () => { await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true); }); - it('can delete a library with assets', async () => { + it('should delete a library with assets', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]); libraryMock.delete.mockImplementation(async () => {}); @@ -1004,7 +1487,7 @@ describe(LibraryService.name, () => { }); describe('queueScan', () => { - it('can queue a library scan of external library', async () => { + it('should queue a library scan of external library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {}); @@ -1023,7 +1506,7 @@ describe(LibraryService.name, () => { ]); }); - it('can not queue a library scan of upload library', async () => { + it('should not queue a library scan of upload library', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf( @@ -1033,7 +1516,7 @@ describe(LibraryService.name, () => { expect(jobMock.queue).not.toBeCalled(); }); - it('can queue a library scan of all modified assets', async () => { + it('should queue a library scan of all modified assets', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); @@ -1052,7 +1535,7 @@ describe(LibraryService.name, () => { ]); }); - it('can queue a forced library scan', async () => { + it('should queue a forced library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true }); @@ -1073,7 +1556,7 @@ describe(LibraryService.name, () => { }); describe('queueEmptyTrash', () => { - it('can queue the trash job', async () => { + it('should queue the trash job', async () => { await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ @@ -1090,7 +1573,7 @@ describe(LibraryService.name, () => { }); describe('handleQueueAllScan', () => { - it('can queue the refresh job', async () => { + it('should queue the refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await expect(sut.handleQueueAllScan({})).resolves.toBe(true); @@ -1115,19 +1598,16 @@ describe(LibraryService.name, () => { ]); }); - it('can queue the force refresh job', async () => { + it('should queue the force refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(true); - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }, - ], - ]); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_QUEUE_CLEANUP, + data: {}, + }); + expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_SCAN, @@ -1142,7 +1622,7 @@ describe(LibraryService.name, () => { }); describe('handleRemoveOfflineFiles', () => { - it('can queue trash deletion jobs', async () => { + it('should queue trash deletion jobs', async () => { assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index cdd5140f1b..b79bb309c8 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -1,5 +1,7 @@ import { AssetType, LibraryType } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import picomatch from 'picomatch'; + import { R_OK } from 'node:constants'; import { Stats } from 'node:fs'; import path from 'node:path'; @@ -11,6 +13,7 @@ import { usePagination, validateCronExpression } from '../domain.util'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { ImmichLogger } from '@app/infra/logger'; +import { EventEmitter } from 'events'; import { IAccessRepository, IAssetRepository, @@ -33,11 +36,15 @@ import { } from './library.dto'; @Injectable() -export class LibraryService { +export class LibraryService extends EventEmitter { readonly logger = new ImmichLogger(LibraryService.name); private access: AccessCore; private configCore: SystemConfigCore; + private watchLibraries = false; + + private watchers: Record Promise> = {}; + constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -48,6 +55,7 @@ export class LibraryService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { + super(); this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); this.configCore.addValidator((config) => { @@ -59,6 +67,7 @@ export class LibraryService { async init() { const config = await this.configCore.getConfig(); + this.watchLibraries = config.library.watch.enabled; this.jobRepository.addCronJob( 'libraryScan', config.library.scan.cronExpression, @@ -66,11 +75,128 @@ export class LibraryService { config.library.scan.enabled, ); - this.configCore.config$.subscribe((config) => { + if (this.watchLibraries) { + await this.watchAll(); + } + + this.configCore.config$.subscribe(async (config) => { this.jobRepository.updateCronJob('libraryScan', config.library.scan.cronExpression, config.library.scan.enabled); + + if (config.library.watch.enabled !== this.watchLibraries) { + this.watchLibraries = config.library.watch.enabled; + if (this.watchLibraries) { + await this.watchAll(); + } else { + await this.unwatchAll(); + } + } }); } + private async watch(id: string): Promise { + if (!this.watchLibraries) { + return false; + } + + const library = await this.findOrFail(id); + + if (library.type !== LibraryType.EXTERNAL) { + throw new BadRequestException('Can only watch external libraries'); + } else if (library.importPaths.length === 0) { + return false; + } + + await this.unwatch(id); + + this.logger.log(`Starting to watch library ${library.id} with import path(s) ${library.importPaths}`); + + const matcher = picomatch(`**/*{${mimeTypes.getSupportedFileExtensions().join(',')}}`, { + nocase: true, + ignore: library.exclusionPatterns, + }); + + const config = await this.configCore.getConfig(); + + this.logger.debug( + `Settings for watcher: usePolling: ${config.library.watch.usePolling}, interval: ${config.library.watch.interval}`, + ); + + const watcher = this.storageRepository.watch(library.importPaths, { + usePolling: config.library.watch.usePolling, + interval: config.library.watch.interval, + binaryInterval: config.library.watch.interval, + ignoreInitial: true, + }); + + this.watchers[id] = async () => { + await watcher.close(); + }; + + watcher.on('add', async (path) => { + this.logger.debug(`File add event received for ${path} in library ${library.id}}`); + if (matcher(path)) { + await this.scanAssets(library.id, [path], library.ownerId, false); + } + this.emit('add', path); + }); + + watcher.on('change', async (path) => { + this.logger.debug(`Detected file change for ${path} in library ${library.id}`); + + if (matcher(path)) { + // Note: if the changed file was not previously imported, it will be imported now. + await this.scanAssets(library.id, [path], library.ownerId, false); + } + this.emit('change', path); + }); + + watcher.on('unlink', async (path) => { + this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); + const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); + + if (existingAssetEntity && matcher(path)) { + await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true }); + } + + this.emit('unlink', path); + }); + + watcher.on('error', async (error) => { + // TODO: should we log, or throw an exception? + this.logger.error(`Library watcher for library ${library.id} encountered error: ${error}`); + }); + + // Wait for the watcher to initialize before returning + await new Promise((resolve) => { + watcher.on('ready', async () => { + resolve(); + }); + }); + + return true; + } + + async unwatch(id: string) { + if (this.watchers.hasOwnProperty(id)) { + await this.watchers[id](); + delete this.watchers[id]; + } + } + + async unwatchAll() { + for (const id in this.watchers) { + await this.unwatch(id); + } + } + + async watchAll() { + const libraries = await this.repository.getAll(false, LibraryType.EXTERNAL); + + for (const library of libraries) { + await this.watch(library.id); + } + } + async getStatistics(auth: AuthDto, id: string): Promise { await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); return this.repository.getStatistics(id); @@ -117,6 +243,9 @@ export class LibraryService { if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) { throw new BadRequestException('Upload libraries cannot have exclusion patterns'); } + if (dto.isWatched) { + throw new BadRequestException('Upload libraries cannot be watched'); + } break; } @@ -129,12 +258,38 @@ export class LibraryService { isVisible: dto.isVisible ?? true, }); + this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`); + + if (dto.type === LibraryType.EXTERNAL && this.watchLibraries) { + await this.watch(library.id); + } + return mapLibrary(library); } + private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { + await this.jobRepository.queueAll( + assetPaths.map((assetPath) => ({ + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryId, + assetPath: path.normalize(assetPath), + ownerId, + force, + }, + })), + ); + } + async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise { await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); const library = await this.repository.update({ id, ...dto }); + + if (dto.importPaths || dto.exclusionPatterns) { + // Re-watch library to use new paths and/or exclusion patterns + await this.watch(id); + } + return mapLibrary(library); } @@ -147,6 +302,10 @@ export class LibraryService { throw new BadRequestException('Cannot delete the last upload library'); } + if (this.watchLibraries) { + await this.unwatch(id); + } + await this.repository.softDelete(id); await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); } @@ -245,8 +404,6 @@ export class LibraryService { const deviceAssetId = `${basename(assetPath)}`.replace(/\s+/g, ''); - const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); - let assetId; if (doImport) { const library = await this.repository.get(job.id, true); @@ -255,6 +412,8 @@ export class LibraryService { return false; } + const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + // TODO: In wait of refactoring the domain asset service, this function is just manually written like this const addedAsset = await this.assetRepository.create({ ownerId: job.ownerId, @@ -387,7 +546,7 @@ export class LibraryService { assetPath.match(new RegExp(`^${user.externalPath}`)), ); - this.logger.debug(`Found ${crawledAssetPaths.length} assets when crawling import paths ${library.importPaths}`); + this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`); const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]); const onlineFiles = new Set(crawledAssetPaths); const offlineAssetIds = assetsInLibrary @@ -411,17 +570,7 @@ export class LibraryService { this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`); } - await this.jobRepository.queueAll( - filteredPaths.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: job.id, - assetPath: path.normalize(assetPath), - ownerId: library.ownerId, - force: job.refreshAllFiles ?? false, - }, - })), - ); + await this.scanAssets(job.id, filteredPaths, library.ownerId, job.refreshAllFiles ?? false); } await this.repository.update({ id: job.id, refreshedAt: new Date() }); diff --git a/server/src/domain/repositories/job.repository.ts b/server/src/domain/repositories/job.repository.ts index 0b52e14590..232040f7ae 100644 --- a/server/src/domain/repositories/job.repository.ts +++ b/server/src/domain/repositories/job.repository.ts @@ -78,7 +78,7 @@ export type JobItem = // Filesystem | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } - // Audit log cleanup + // Audit Log Cleanup | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } // Asset Deletion diff --git a/server/src/domain/repositories/storage.repository.ts b/server/src/domain/repositories/storage.repository.ts index 902980a7b2..8a01c73d51 100644 --- a/server/src/domain/repositories/storage.repository.ts +++ b/server/src/domain/repositories/storage.repository.ts @@ -1,3 +1,4 @@ +import { FSWatcher, WatchOptions } from 'chokidar'; import { Stats } from 'fs'; import { FileReadOptions } from 'fs/promises'; import { Readable } from 'stream'; @@ -22,6 +23,8 @@ export interface DiskUsage { export const IStorageRepository = 'IStorageRepository'; +export interface ImmichWatcher extends FSWatcher {} + export interface IStorageRepository { createZipStream(): ImmichZipStream; createReadStream(filepath: string, mimeType?: string | null): Promise; @@ -38,4 +41,5 @@ export interface IStorageRepository { crawl(crawlOptions: CrawlOptionsDto): Promise; copyFile(source: string, target: string): Promise; rename(source: string, target: string): Promise; + watch(paths: string[], options: WatchOptions): ImmichWatcher; } diff --git a/server/src/domain/system-config/dto/system-config-library.dto.ts b/server/src/domain/system-config/dto/system-config-library.dto.ts index 2280e70938..caf73498f2 100644 --- a/server/src/domain/system-config/dto/system-config-library.dto.ts +++ b/server/src/domain/system-config/dto/system-config-library.dto.ts @@ -1,9 +1,12 @@ import { validateCronExpression } from '@app/domain'; +import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, + IsInt, IsNotEmpty, IsObject, + IsPositive, IsString, Validate, ValidateIf, @@ -32,9 +35,27 @@ export class SystemConfigLibraryScanDto { cronExpression!: string; } +export class SystemConfigLibraryWatchDto { + @IsBoolean() + enabled!: boolean; + + @IsBoolean() + usePolling!: boolean; + + @IsInt() + @IsPositive() + @ApiProperty({ type: 'integer' }) + interval!: number; +} + export class SystemConfigLibraryDto { @Type(() => SystemConfigLibraryScanDto) @ValidateNested() @IsObject() scan!: SystemConfigLibraryScanDto; + + @Type(() => SystemConfigLibraryWatchDto) + @ValidateNested() + @IsObject() + watch!: SystemConfigLibraryWatchDto; } diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 6be0ee81a1..8a33c7061a 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -129,6 +129,11 @@ export const defaults = Object.freeze({ enabled: true, cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT, }, + watch: { + enabled: false, + usePolling: false, + interval: 10000, + }, }, server: { externalDomain: '', diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 469e118a9b..9a5862db05 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -133,6 +133,11 @@ const updatedConfig = Object.freeze({ enabled: true, cronExpression: '0 0 * * *', }, + watch: { + enabled: false, + usePolling: false, + interval: 10000, + }, }, }); diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index 56bf649164..0b3a18577c 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -71,6 +71,10 @@ export class AppService { this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); } + async teardown() { + await this.libraryService.unwatchAll(); + } + ssr(excludePaths: string[]) { let index = ''; try { diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 209d0db19c..a6adf50ddd 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -50,6 +50,13 @@ export enum SystemConfigKey { LIBRARY_SCAN_ENABLED = 'library.scan.enabled', LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression', + LIBRARY_WATCH_ENABLED = 'library.watch.enabled', + LIBRARY_WATCH_USE_POLLING = 'library.watch.usePolling', + LIBRARY_WATCH_INTERVAL = 'library.watch.interval', + LIBRARY_WATCH_BINARY_INTERVAL = 'library.watch.binaryInterval', + LIBRARY_WATCH_WRITE_STABILITY_THRESHOLD = 'library.watch.awaitWriteFinish.stabilityThreshold', + LIBRARY_WATCH_WRITE_POLL_INTERVAL = 'library.watch.awaitWriteFinish.pollInterval', + LOGGING_ENABLED = 'logging.enabled', LOGGING_LEVEL = 'logging.level', @@ -253,6 +260,11 @@ export interface SystemConfig { enabled: boolean; cronExpression: string; }; + watch: { + enabled: boolean; + usePolling: boolean; + interval: number; + }; }; server: { externalDomain: string; diff --git a/server/src/infra/repositories/filesystem.provider.spec.ts b/server/src/infra/repositories/filesystem.provider.spec.ts index 5103e95086..4c20b2a506 100644 --- a/server/src/infra/repositories/filesystem.provider.spec.ts +++ b/server/src/infra/repositories/filesystem.provider.spec.ts @@ -180,7 +180,11 @@ const tests: Test[] = [ ]; describe(FilesystemProvider.name, () => { - const sut = new FilesystemProvider(); + let sut: FilesystemProvider; + + beforeEach(() => { + sut = new FilesystemProvider(); + }); afterEach(() => { mockfs.restore(); diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index 5bdfaa2e37..c9b44845d4 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -2,12 +2,14 @@ import { CrawlOptionsDto, DiskUsage, ImmichReadStream, + ImmichWatcher, ImmichZipStream, IStorageRepository, mimeTypes, } from '@app/domain'; import { ImmichLogger } from '@app/infra/logger'; import archiver from 'archiver'; +import chokidar, { WatchOptions } from 'chokidar'; import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import fs, { copyFile, readdir, rename, writeFile } from 'fs/promises'; import { glob } from 'glob'; @@ -132,5 +134,9 @@ export class FilesystemProvider implements IStorageRepository { }); } + watch(paths: string[], options: WatchOptions): ImmichWatcher { + return chokidar.watch(paths, options); + } + readdir = readdir; } diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 93870583ff..3109b7f782 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -3,6 +3,7 @@ import { AuditService, DatabaseService, IDeleteFilesJob, + IStorageRepository, JobName, JobService, LibraryService, @@ -15,7 +16,7 @@ import { SystemConfigService, UserService, } from '@app/domain'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; @Injectable() export class AppService { @@ -33,6 +34,7 @@ export class AppService { private storageService: StorageService, private userService: UserService, private databaseService: DatabaseService, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) {} async init() { diff --git a/server/src/test-utils/utils.ts b/server/src/test-utils/utils.ts index a1185d3f20..67ad5fff34 100644 --- a/server/src/test-utils/utils.ts +++ b/server/src/test-utils/utils.ts @@ -8,8 +8,10 @@ import { DateTime } from 'luxon'; import * as fs from 'node:fs'; import path from 'node:path'; import { Server } from 'node:tls'; +import { EventEmitter } from 'stream'; import { EntityTarget, ObjectLiteral } from 'typeorm'; -import { AppService } from '../microservices/app.service'; +import { AppService } from '../immich/app.service'; +import { AppService as MicroAppService } from '../microservices/app.service'; export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH as string; export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`); @@ -95,7 +97,10 @@ let app: INestApplication; export const testApp = { create: async (): Promise => { - const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] }) + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + providers: [AppService, MicroAppService], + }) .overrideModule(InfraModule) .useModule(InfraTestModule) .overrideProvider(IJobRepository) @@ -106,7 +111,9 @@ export const testApp = { app = await moduleFixture.createNestApplication().init(); await app.listen(0); + await db.reset(); await app.get(AppService).init(); + await app.get(MicroAppService).init(); const port = app.getHttpServer().address().port; const protocol = app instanceof Server ? 'https' : 'http'; @@ -115,11 +122,15 @@ export const testApp = { return app; }, reset: async (options?: ResetOptions) => { - await app.get(AppService).init(); await db.reset(options); + await app.get(AppService).init(); + + await app.get(MicroAppService).init(); }, + get: (member: any) => app.get(member), teardown: async () => { if (app) { + await app.get(MicroAppService).teardown(); await app.get(AppService).teardown(); await app.close(); } @@ -127,6 +138,21 @@ export const testApp = { }, }; +export function waitForEvent(emitter: EventEmitter, event: string): Promise { + return new Promise((resolve, reject) => { + const success = (val: T) => { + emitter.off('error', fail); + resolve(val); + }; + const fail = (err: Error) => { + emitter.off(event, success); + reject(err); + }; + emitter.once(event, success); + emitter.once('error', fail); + }); +} + const directoryExists = async (dirPath: string) => await fs.promises .access(dirPath) diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts index edcd305214..3a203ce87c 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -30,4 +30,74 @@ export const libraryStub = { isVisible: true, exclusionPatterns: [], }), + externalLibrary2: Object.freeze({ + id: 'library-id2', + name: 'test_library2', + assets: [], + owner: userStub.externalPath1, + ownerId: 'user-id', + type: LibraryType.EXTERNAL, + importPaths: [], + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2022-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: [], + }), + externalLibraryWithImportPaths1: Object.freeze({ + id: 'library-id-with-paths1', + name: 'library-with-import-paths1', + assets: [], + owner: userStub.externalPath1, + ownerId: 'user-id', + type: LibraryType.EXTERNAL, + importPaths: ['/foo', '/bar'], + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: [], + }), + externalLibraryWithImportPaths2: Object.freeze({ + id: 'library-id-with-paths2', + name: 'library-with-import-paths2', + assets: [], + owner: userStub.externalPath1, + ownerId: 'user-id', + type: LibraryType.EXTERNAL, + importPaths: ['/xyz', '/asdf'], + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: [], + }), + externalLibraryWithExclusionPattern: Object.freeze({ + id: 'library-id', + name: 'test_library', + assets: [], + owner: userStub.externalPath1, + ownerId: 'user-id', + type: LibraryType.EXTERNAL, + importPaths: [], + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: ['**/dir1/**'], + }), + patternPath: Object.freeze({ + id: 'library-id1337', + name: 'importpath-exclusion-library1', + assets: [], + owner: userStub.externalPath1, + ownerId: 'user-id', + type: LibraryType.EXTERNAL, + importPaths: ['/xyz', '/asdf'], + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: ['**/dir1/**'], + }), }; diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index dd34910717..721bb181c4 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -22,4 +22,6 @@ export const systemConfigStub: Record = { { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' }, { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, ], + libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }], + libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }], }; diff --git a/server/test/mocks/fswatcher.mock.ts b/server/test/mocks/fswatcher.mock.ts new file mode 100644 index 0000000000..5699005964 --- /dev/null +++ b/server/test/mocks/fswatcher.mock.ts @@ -0,0 +1,24 @@ +export const newFSWatcherMock = () => { + return { + options: {}, + on: jest.fn(), + add: jest.fn(), + unwatch: jest.fn(), + getWatched: jest.fn(), + close: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + eventNames: jest.fn(), + rawListeners: jest.fn(), + listeners: jest.fn(), + emit: jest.fn(), + listenerCount: jest.fn(), + off: jest.fn(), + once: jest.fn(), + prependListener: jest.fn(), + prependOnceListener: jest.fn(), + setMaxListeners: jest.fn(), + getMaxListeners: jest.fn(), + }; +}; diff --git a/server/test/mocks/index.ts b/server/test/mocks/index.ts new file mode 100644 index 0000000000..e2611ef304 --- /dev/null +++ b/server/test/mocks/index.ts @@ -0,0 +1 @@ +export * from './fswatcher.mock'; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index a6a088c543..f4dbc0c5b1 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -21,5 +21,6 @@ export const newStorageRepositoryMock = (reset = true): jest.Mocked
- + +
+
+ + + + + + +

+ Interval of filesystem polling, in milliseconds. Lower values will result in higher CPU usage. +

+
+
+
+ +
+ dispatch('reset', { ...detail, configKeys: ['library'] })} + on:save={() => dispatch('save', { library: config.library })} + showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)} + {disabled} + /> +
+
+
+ +