mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 02:27:08 -04:00 
			
		
		
		
	refactor(server): library service (#8050)
* refactor: library service * chore: open api * fix: checks
This commit is contained in:
		
							parent
							
								
									761e7fdd2d
								
							
						
					
					
						commit
						40262c30cb
					
				| @ -27,7 +27,7 @@ describe('/library', () => { | ||||
|     await utils.resetDatabase(); | ||||
|     admin = await utils.adminSetup(); | ||||
|     user = await utils.userSetup(admin.accessToken, userDto.user1); | ||||
|     library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); | ||||
|     library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External }); | ||||
|     websocket = await utils.connectWebsocket(admin.accessToken); | ||||
|   }); | ||||
| 
 | ||||
| @ -82,7 +82,7 @@ describe('/library', () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${user.accessToken}`) | ||||
|         .send({ type: LibraryType.External }); | ||||
|         .send({ ownerId: admin.userId, type: LibraryType.External }); | ||||
| 
 | ||||
|       expect(status).toBe(403); | ||||
|       expect(body).toEqual(errorDto.forbidden); | ||||
| @ -92,7 +92,7 @@ describe('/library', () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ type: LibraryType.External }); | ||||
|         .send({ ownerId: admin.userId, type: LibraryType.External }); | ||||
| 
 | ||||
|       expect(status).toBe(201); | ||||
|       expect(body).toEqual( | ||||
| @ -113,6 +113,7 @@ describe('/library', () => { | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.External, | ||||
|           name: 'My Awesome Library', | ||||
|           importPaths: ['/path/to/import'], | ||||
| @ -133,6 +134,7 @@ describe('/library', () => { | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.External, | ||||
|           name: 'My Awesome Library', | ||||
|           importPaths: ['/path', '/path'], | ||||
| @ -148,6 +150,7 @@ describe('/library', () => { | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.External, | ||||
|           name: 'My Awesome Library', | ||||
|           importPaths: ['/path/to/import'], | ||||
| @ -162,7 +165,7 @@ describe('/library', () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ type: LibraryType.Upload }); | ||||
|         .send({ ownerId: admin.userId, type: LibraryType.Upload }); | ||||
| 
 | ||||
|       expect(status).toBe(201); | ||||
|       expect(body).toEqual( | ||||
| @ -182,7 +185,7 @@ describe('/library', () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ type: LibraryType.Upload, name: 'My Awesome Library' }); | ||||
|         .send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' }); | ||||
| 
 | ||||
|       expect(status).toBe(201); | ||||
|       expect(body).toEqual( | ||||
| @ -196,7 +199,7 @@ describe('/library', () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] }); | ||||
|         .send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] }); | ||||
| 
 | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths')); | ||||
| @ -206,7 +209,7 @@ describe('/library', () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/library') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); | ||||
|         .send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); | ||||
| 
 | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns')); | ||||
| @ -330,7 +333,10 @@ describe('/library', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('should get library by id', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.External, | ||||
|       }); | ||||
| 
 | ||||
|       const { status, body } = await request(app) | ||||
|         .get(`/library/${library.id}`) | ||||
| @ -386,7 +392,10 @@ describe('/library', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('should delete an external library', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.External, | ||||
|       }); | ||||
| 
 | ||||
|       const { status, body } = await request(app) | ||||
|         .delete(`/library/${library.id}`) | ||||
| @ -407,6 +416,7 @@ describe('/library', () => { | ||||
| 
 | ||||
|     it('should delete an external library with assets', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.External, | ||||
|         importPaths: [`${testAssetDirInternal}/temp`], | ||||
|       }); | ||||
| @ -455,6 +465,7 @@ describe('/library', () => { | ||||
| 
 | ||||
|     it('should not scan an upload library', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.Upload, | ||||
|       }); | ||||
| 
 | ||||
| @ -468,6 +479,7 @@ describe('/library', () => { | ||||
| 
 | ||||
|     it('should scan external library', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.External, | ||||
|         importPaths: [`${testAssetDirInternal}/temp/directoryA`], | ||||
|       }); | ||||
| @ -483,6 +495,7 @@ describe('/library', () => { | ||||
| 
 | ||||
|     it('should scan external library with exclusion pattern', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.External, | ||||
|         importPaths: [`${testAssetDirInternal}/temp`], | ||||
|         exclusionPatterns: ['**/directoryA'], | ||||
| @ -499,6 +512,7 @@ describe('/library', () => { | ||||
| 
 | ||||
|     it('should scan multiple import paths', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.External, | ||||
|         importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], | ||||
|       }); | ||||
| @ -515,6 +529,7 @@ describe('/library', () => { | ||||
| 
 | ||||
|     it('should pick up new files', async () => { | ||||
|       const library = await utils.createLibrary(admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.External, | ||||
|         importPaths: [`${testAssetDirInternal}/temp`], | ||||
|       }); | ||||
|  | ||||
							
								
								
									
										2
									
								
								mobile/openapi/doc/CreateLibraryDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/CreateLibraryDto.md
									
									
									
										generated
									
									
									
								
							| @ -13,7 +13,7 @@ Name | Type | Description | Notes | ||||
| **isVisible** | **bool** |  | [optional]  | ||||
| **isWatched** | **bool** |  | [optional]  | ||||
| **name** | **String** |  | [optional]  | ||||
| **ownerId** | **String** |  | [optional]  | ||||
| **ownerId** | **String** |  |  | ||||
| **type** | [**LibraryType**](LibraryType.md) |  |  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|  | ||||
| @ -9,7 +9,7 @@ import 'package:openapi/api.dart'; | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **importPath** | **String** |  |  | ||||
| **isValid** | **bool** |  | [optional] [default to false] | ||||
| **isValid** | **bool** |  | [default to false] | ||||
| **message** | **String** |  | [optional]  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|  | ||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/create_library_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/create_library_dto.dart
									
									
									
										generated
									
									
									
								
							| @ -18,7 +18,7 @@ class CreateLibraryDto { | ||||
|     this.isVisible, | ||||
|     this.isWatched, | ||||
|     this.name, | ||||
|     this.ownerId, | ||||
|     required this.ownerId, | ||||
|     required this.type, | ||||
|   }); | ||||
| 
 | ||||
| @ -50,13 +50,7 @@ class CreateLibraryDto { | ||||
|   /// | ||||
|   String? name; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? ownerId; | ||||
|   String ownerId; | ||||
| 
 | ||||
|   LibraryType type; | ||||
| 
 | ||||
| @ -78,7 +72,7 @@ class CreateLibraryDto { | ||||
|     (isVisible == null ? 0 : isVisible!.hashCode) + | ||||
|     (isWatched == null ? 0 : isWatched!.hashCode) + | ||||
|     (name == null ? 0 : name!.hashCode) + | ||||
|     (ownerId == null ? 0 : ownerId!.hashCode) + | ||||
|     (ownerId.hashCode) + | ||||
|     (type.hashCode); | ||||
| 
 | ||||
|   @override | ||||
| @ -103,11 +97,7 @@ class CreateLibraryDto { | ||||
|     } else { | ||||
|     //  json[r'name'] = null; | ||||
|     } | ||||
|     if (this.ownerId != null) { | ||||
|       json[r'ownerId'] = this.ownerId; | ||||
|     } else { | ||||
|     //  json[r'ownerId'] = null; | ||||
|     } | ||||
|       json[r'type'] = this.type; | ||||
|     return json; | ||||
|   } | ||||
| @ -129,7 +119,7 @@ class CreateLibraryDto { | ||||
|         isVisible: mapValueOfType<bool>(json, r'isVisible'), | ||||
|         isWatched: mapValueOfType<bool>(json, r'isWatched'), | ||||
|         name: mapValueOfType<String>(json, r'name'), | ||||
|         ownerId: mapValueOfType<String>(json, r'ownerId'), | ||||
|         ownerId: mapValueOfType<String>(json, r'ownerId')!, | ||||
|         type: LibraryType.fromJson(json[r'type'])!, | ||||
|       ); | ||||
|     } | ||||
| @ -178,6 +168,7 @@ class CreateLibraryDto { | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'ownerId', | ||||
|     'type', | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @ -67,7 +67,7 @@ class ValidateLibraryImportPathResponseDto { | ||||
| 
 | ||||
|       return ValidateLibraryImportPathResponseDto( | ||||
|         importPath: mapValueOfType<String>(json, r'importPath')!, | ||||
|         isValid: mapValueOfType<bool>(json, r'isValid') ?? false, | ||||
|         isValid: mapValueOfType<bool>(json, r'isValid')!, | ||||
|         message: mapValueOfType<String>(json, r'message'), | ||||
|       ); | ||||
|     } | ||||
| @ -117,6 +117,7 @@ class ValidateLibraryImportPathResponseDto { | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'importPath', | ||||
|     'isValid', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -7646,6 +7646,7 @@ | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "ownerId", | ||||
|           "type" | ||||
|         ], | ||||
|         "type": "object" | ||||
| @ -10689,7 +10690,8 @@ | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "importPath" | ||||
|           "importPath", | ||||
|           "isValid" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|  | ||||
| @ -466,7 +466,7 @@ export type CreateLibraryDto = { | ||||
|     isVisible?: boolean; | ||||
|     isWatched?: boolean; | ||||
|     name?: string; | ||||
|     ownerId?: string; | ||||
|     ownerId: string; | ||||
|     "type": LibraryType; | ||||
| }; | ||||
| export type UpdateLibraryDto = { | ||||
| @ -491,7 +491,7 @@ export type ValidateLibraryDto = { | ||||
| }; | ||||
| export type ValidateLibraryImportPathResponseDto = { | ||||
|     importPath: string; | ||||
|     isValid?: boolean; | ||||
|     isValid: boolean; | ||||
|     message?: string; | ||||
| }; | ||||
| export type ValidateLibraryResponseDto = { | ||||
|  | ||||
| @ -46,6 +46,7 @@ describe(`Library watcher (e2e)`, () => { | ||||
|     describe('Single import path', () => { | ||||
|       beforeEach(async () => { | ||||
|         await api.libraryApi.create(server, admin.accessToken, { | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.EXTERNAL, | ||||
|           importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], | ||||
|         }); | ||||
| @ -133,6 +134,7 @@ describe(`Library watcher (e2e)`, () => { | ||||
|         await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true }); | ||||
| 
 | ||||
|         await api.libraryApi.create(server, admin.accessToken, { | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.EXTERNAL, | ||||
|           importPaths: [ | ||||
|             `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, | ||||
| @ -190,6 +192,7 @@ describe(`Library watcher (e2e)`, () => { | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|       library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.EXTERNAL, | ||||
|         importPaths: [ | ||||
|           `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; | ||||
| import { LoginResponseDto } from '@app/domain'; | ||||
| import { LibraryController } from '@app/immich'; | ||||
| import { AssetType, LibraryType } from '@app/infra/entities'; | ||||
| import { LibraryType } from '@app/infra/entities'; | ||||
| import { errorStub, uuidStub } from '@test/fixtures'; | ||||
| import * as fs from 'node:fs'; | ||||
| import request from 'supertest'; | ||||
| @ -41,6 +41,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | ||||
|       }); | ||||
| 
 | ||||
|       const library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.EXTERNAL, | ||||
|         importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], | ||||
|       }); | ||||
| @ -72,6 +73,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | ||||
| 
 | ||||
|     it('should scan new files', async () => { | ||||
|       const library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.EXTERNAL, | ||||
|         importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], | ||||
|       }); | ||||
| @ -107,6 +109,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | ||||
|     describe('with refreshModifiedFiles=true', () => { | ||||
|       it('should reimport modified files', async () => { | ||||
|         const library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.EXTERNAL, | ||||
|           importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], | ||||
|         }); | ||||
| @ -153,6 +156,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | ||||
| 
 | ||||
|       it('should not reimport unmodified files', async () => { | ||||
|         const library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.EXTERNAL, | ||||
|           importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], | ||||
|         }); | ||||
| @ -192,6 +196,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | ||||
|     describe('with refreshAllFiles=true', () => { | ||||
|       it('should reimport all files', async () => { | ||||
|         const library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|           ownerId: admin.userId, | ||||
|           type: LibraryType.EXTERNAL, | ||||
|           importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], | ||||
|         }); | ||||
| @ -251,6 +256,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | ||||
|       }); | ||||
| 
 | ||||
|       const library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.EXTERNAL, | ||||
|         importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], | ||||
|       }); | ||||
| @ -277,6 +283,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | ||||
| 
 | ||||
|     it('should not remove online files', async () => { | ||||
|       const library = await api.libraryApi.create(server, admin.accessToken, { | ||||
|         ownerId: admin.userId, | ||||
|         type: LibraryType.EXTERNAL, | ||||
|         importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], | ||||
|       }); | ||||
|  | ||||
| @ -153,7 +153,7 @@ | ||||
|     "coverageDirectory": "./coverage", | ||||
|     "coverageThreshold": { | ||||
|       "./src/domain/": { | ||||
|         "branches": 79, | ||||
|         "branches": 75, | ||||
|         "functions": 80, | ||||
|         "lines": 90, | ||||
|         "statements": 90 | ||||
|  | ||||
| @ -33,12 +33,6 @@ export enum Permission { | ||||
|   TIMELINE_READ = 'timeline.read', | ||||
|   TIMELINE_DOWNLOAD = 'timeline.download', | ||||
| 
 | ||||
|   LIBRARY_CREATE = 'library.create', | ||||
|   LIBRARY_READ = 'library.read', | ||||
|   LIBRARY_UPDATE = 'library.update', | ||||
|   LIBRARY_DELETE = 'library.delete', | ||||
|   LIBRARY_DOWNLOAD = 'library.download', | ||||
| 
 | ||||
|   PERSON_READ = 'person.read', | ||||
|   PERSON_WRITE = 'person.write', | ||||
|   PERSON_MERGE = 'person.merge', | ||||
| @ -261,29 +255,6 @@ export class AccessCore { | ||||
|         return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); | ||||
|       } | ||||
| 
 | ||||
|       case Permission.LIBRARY_READ: { | ||||
|         if (auth.user.isAdmin) { | ||||
|           return new Set(ids); | ||||
|         } | ||||
|         const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids); | ||||
|         const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); | ||||
|         return setUnion(isOwner, isPartner); | ||||
|       } | ||||
| 
 | ||||
|       case Permission.LIBRARY_UPDATE: { | ||||
|         if (auth.user.isAdmin) { | ||||
|           return new Set(ids); | ||||
|         } | ||||
|         return await this.repository.library.checkOwnerAccess(auth.user.id, ids); | ||||
|       } | ||||
| 
 | ||||
|       case Permission.LIBRARY_DELETE: { | ||||
|         if (auth.user.isAdmin) { | ||||
|           return new Set(ids); | ||||
|         } | ||||
|         return await this.repository.library.checkOwnerAccess(auth.user.id, ids); | ||||
|       } | ||||
| 
 | ||||
|       case Permission.PERSON_READ: { | ||||
|         return await this.repository.person.checkOwnerAccess(auth.user.id, ids); | ||||
|       } | ||||
|  | ||||
| @ -8,8 +8,8 @@ export class CreateLibraryDto { | ||||
|   @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) | ||||
|   type!: LibraryType; | ||||
| 
 | ||||
|   @ValidateUUID({ optional: true }) | ||||
|   ownerId?: string; | ||||
|   @ValidateUUID() | ||||
|   ownerId!: string; | ||||
| 
 | ||||
|   @IsString() | ||||
|   @Optional() | ||||
|  | ||||
| @ -706,7 +706,7 @@ describe(LibraryService.name, () => { | ||||
|       libraryMock.getUploadLibraryCount.mockResolvedValue(2); | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); | ||||
| 
 | ||||
|       await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); | ||||
|       await sut.delete(libraryStub.externalLibrary1.id); | ||||
| 
 | ||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||
|         name: JobName.LIBRARY_DELETE, | ||||
| @ -721,9 +721,7 @@ describe(LibraryService.name, () => { | ||||
|       libraryMock.getUploadLibraryCount.mockResolvedValue(1); | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); | ||||
| 
 | ||||
|       await expect(sut.delete(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf( | ||||
|         BadRequestException, | ||||
|       ); | ||||
|       await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); | ||||
| 
 | ||||
|       expect(jobMock.queue).not.toHaveBeenCalled(); | ||||
|       expect(jobMock.queueAll).not.toHaveBeenCalled(); | ||||
| @ -735,7 +733,7 @@ describe(LibraryService.name, () => { | ||||
|       libraryMock.getUploadLibraryCount.mockResolvedValue(1); | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); | ||||
| 
 | ||||
|       await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); | ||||
|       await sut.delete(libraryStub.externalLibrary1.id); | ||||
| 
 | ||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||
|         name: JobName.LIBRARY_DELETE, | ||||
| @ -757,26 +755,16 @@ describe(LibraryService.name, () => { | ||||
|       storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); | ||||
| 
 | ||||
|       await sut.init(); | ||||
|       await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id); | ||||
|       await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); | ||||
| 
 | ||||
|       expect(mockClose).toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('getCount', () => { | ||||
|     it('should call the repository', async () => { | ||||
|       libraryMock.getCountForUser.mockResolvedValue(17); | ||||
| 
 | ||||
|       await expect(sut.getCount(authStub.admin)).resolves.toBe(17); | ||||
| 
 | ||||
|       expect(libraryMock.getCountForUser).toHaveBeenCalledWith(authStub.admin.user.id); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('get', () => { | ||||
|     it('should return a library', async () => { | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); | ||||
|       await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual( | ||||
|       await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual( | ||||
|         expect.objectContaining({ | ||||
|           id: libraryStub.uploadLibrary1.id, | ||||
|           name: libraryStub.uploadLibrary1.name, | ||||
| @ -789,15 +777,16 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|     it('should throw an error when a library is not found', async () => { | ||||
|       libraryMock.get.mockResolvedValue(null); | ||||
|       await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); | ||||
|       await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); | ||||
|       expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('getStatistics', () => { | ||||
|     it('should return library statistics', async () => { | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); | ||||
|       libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); | ||||
|       await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({ | ||||
|       await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({ | ||||
|         photos: 10, | ||||
|         videos: 0, | ||||
|         total: 10, | ||||
| @ -812,11 +801,7 @@ describe(LibraryService.name, () => { | ||||
|     describe('external library', () => { | ||||
|       it('should create with default settings', async () => { | ||||
|         libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|             type: LibraryType.EXTERNAL, | ||||
|           }), | ||||
|         ).resolves.toEqual( | ||||
|         await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual( | ||||
|           expect.objectContaining({ | ||||
|             id: libraryStub.externalLibrary1.id, | ||||
|             type: LibraryType.EXTERNAL, | ||||
| @ -845,10 +830,7 @@ describe(LibraryService.name, () => { | ||||
|       it('should create with name', async () => { | ||||
|         libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|             type: LibraryType.EXTERNAL, | ||||
|             name: 'My Awesome Library', | ||||
|           }), | ||||
|           sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }), | ||||
|         ).resolves.toEqual( | ||||
|           expect.objectContaining({ | ||||
|             id: libraryStub.externalLibrary1.id, | ||||
| @ -878,10 +860,7 @@ describe(LibraryService.name, () => { | ||||
|       it('should create invisible', async () => { | ||||
|         libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|             type: LibraryType.EXTERNAL, | ||||
|             isVisible: false, | ||||
|           }), | ||||
|           sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, isVisible: false }), | ||||
|         ).resolves.toEqual( | ||||
|           expect.objectContaining({ | ||||
|             id: libraryStub.externalLibrary1.id, | ||||
| @ -911,7 +890,8 @@ describe(LibraryService.name, () => { | ||||
|       it('should create with import paths', async () => { | ||||
|         libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|           sut.create({ | ||||
|             ownerId: authStub.admin.user.id, | ||||
|             type: LibraryType.EXTERNAL, | ||||
|             importPaths: ['/data/images', '/data/videos'], | ||||
|           }), | ||||
| @ -948,7 +928,8 @@ describe(LibraryService.name, () => { | ||||
|         libraryMock.getAll.mockResolvedValue([]); | ||||
| 
 | ||||
|         await sut.init(); | ||||
|         await sut.create(authStub.admin, { | ||||
|         await sut.create({ | ||||
|           ownerId: authStub.admin.user.id, | ||||
|           type: LibraryType.EXTERNAL, | ||||
|           importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, | ||||
|         }); | ||||
| @ -963,7 +944,8 @@ describe(LibraryService.name, () => { | ||||
|       it('should create with exclusion patterns', async () => { | ||||
|         libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|           sut.create({ | ||||
|             ownerId: authStub.admin.user.id, | ||||
|             type: LibraryType.EXTERNAL, | ||||
|             exclusionPatterns: ['*.tmp', '*.bak'], | ||||
|           }), | ||||
| @ -997,11 +979,7 @@ describe(LibraryService.name, () => { | ||||
|     describe('upload library', () => { | ||||
|       it('should create with default settings', async () => { | ||||
|         libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|             type: LibraryType.UPLOAD, | ||||
|           }), | ||||
|         ).resolves.toEqual( | ||||
|         await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual( | ||||
|           expect.objectContaining({ | ||||
|             id: libraryStub.uploadLibrary1.id, | ||||
|             type: LibraryType.UPLOAD, | ||||
| @ -1030,10 +1008,7 @@ describe(LibraryService.name, () => { | ||||
|       it('should create with name', async () => { | ||||
|         libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|             type: LibraryType.UPLOAD, | ||||
|             name: 'My Awesome Library', | ||||
|           }), | ||||
|           sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }), | ||||
|         ).resolves.toEqual( | ||||
|           expect.objectContaining({ | ||||
|             id: libraryStub.uploadLibrary1.id, | ||||
| @ -1062,7 +1037,8 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|       it('should not create with import paths', async () => { | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|           sut.create({ | ||||
|             ownerId: authStub.admin.user.id, | ||||
|             type: LibraryType.UPLOAD, | ||||
|             importPaths: ['/data/images', '/data/videos'], | ||||
|           }), | ||||
| @ -1073,7 +1049,8 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|       it('should not create with exclusion patterns', async () => { | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|           sut.create({ | ||||
|             ownerId: authStub.admin.user.id, | ||||
|             type: LibraryType.UPLOAD, | ||||
|             exclusionPatterns: ['*.tmp', '*.bak'], | ||||
|           }), | ||||
| @ -1084,10 +1061,7 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|       it('should not create watched', async () => { | ||||
|         await expect( | ||||
|           sut.create(authStub.admin, { | ||||
|             type: LibraryType.UPLOAD, | ||||
|             isWatched: true, | ||||
|           }), | ||||
|           sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }), | ||||
|         ).rejects.toBeInstanceOf(BadRequestException); | ||||
| 
 | ||||
|         expect(storageMock.watch).not.toHaveBeenCalled(); | ||||
| @ -1117,14 +1091,9 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|     it('should update library', async () => { | ||||
|       libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); | ||||
|       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, | ||||
|         }), | ||||
|       ); | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); | ||||
|       await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1)); | ||||
|       expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); | ||||
|     }); | ||||
| 
 | ||||
|     it('should re-watch library when updating import paths', async () => { | ||||
| @ -1137,15 +1106,11 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|       storageMock.checkFileExists.mockResolvedValue(true); | ||||
| 
 | ||||
|       await expect( | ||||
|         sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }), | ||||
|       ).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1)); | ||||
| 
 | ||||
|       expect(libraryMock.update).toHaveBeenCalledWith( | ||||
|         expect.objectContaining({ | ||||
|           id: authStub.admin.user.id, | ||||
|         }), | ||||
|       await expect(sut.update('library-id', { importPaths: ['/data/user1/foo'] })).resolves.toEqual( | ||||
|         mapLibrary(libraryStub.externalLibraryWithImportPaths1), | ||||
|       ); | ||||
| 
 | ||||
|       expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); | ||||
|       expect(storageMock.watch).toHaveBeenCalledWith( | ||||
|         libraryStub.externalLibraryWithImportPaths1.importPaths, | ||||
|         expect.anything(), | ||||
| @ -1158,15 +1123,11 @@ describe(LibraryService.name, () => { | ||||
|       configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); | ||||
| 
 | ||||
|       await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual( | ||||
|       await expect(sut.update('library-id', { exclusionPatterns: ['bar'] })).resolves.toEqual( | ||||
|         mapLibrary(libraryStub.externalLibraryWithImportPaths1), | ||||
|       ); | ||||
| 
 | ||||
|       expect(libraryMock.update).toHaveBeenCalledWith( | ||||
|         expect.objectContaining({ | ||||
|           id: authStub.admin.user.id, | ||||
|         }), | ||||
|       ); | ||||
|       expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); | ||||
|       expect(storageMock.watch).toHaveBeenCalledWith( | ||||
|         expect.arrayContaining([expect.any(String)]), | ||||
|         expect.anything(), | ||||
| @ -1411,7 +1372,7 @@ describe(LibraryService.name, () => { | ||||
|     it('should queue a library scan of external library', async () => { | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); | ||||
| 
 | ||||
|       await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {}); | ||||
|       await sut.queueScan(libraryStub.externalLibrary1.id, {}); | ||||
| 
 | ||||
|       expect(jobMock.queue.mock.calls).toEqual([ | ||||
|         [ | ||||
| @ -1430,9 +1391,7 @@ describe(LibraryService.name, () => { | ||||
|     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( | ||||
|         BadRequestException, | ||||
|       ); | ||||
|       await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException); | ||||
| 
 | ||||
|       expect(jobMock.queue).not.toBeCalled(); | ||||
|     }); | ||||
| @ -1440,7 +1399,7 @@ describe(LibraryService.name, () => { | ||||
|     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 }); | ||||
|       await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); | ||||
| 
 | ||||
|       expect(jobMock.queue.mock.calls).toEqual([ | ||||
|         [ | ||||
| @ -1459,7 +1418,7 @@ describe(LibraryService.name, () => { | ||||
|     it('should queue a forced library scan', async () => { | ||||
|       libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); | ||||
| 
 | ||||
|       await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true }); | ||||
|       await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); | ||||
| 
 | ||||
|       expect(jobMock.queue.mock.calls).toEqual([ | ||||
|         [ | ||||
| @ -1478,7 +1437,7 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|   describe('queueEmptyTrash', () => { | ||||
|     it('should queue the trash job', async () => { | ||||
|       await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id); | ||||
|       await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); | ||||
| 
 | ||||
|       expect(jobMock.queue.mock.calls).toEqual([ | ||||
|         [ | ||||
| @ -1566,17 +1525,15 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|       storageMock.checkFileExists.mockResolvedValue(true); | ||||
| 
 | ||||
|       const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { | ||||
|         importPaths: ['/data/user1/'], | ||||
|       await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ | ||||
|         importPaths: [ | ||||
|           { | ||||
|             importPath: '/data/user1/', | ||||
|             isValid: true, | ||||
|             message: undefined, | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
| 
 | ||||
|       expect(result.importPaths).toEqual([ | ||||
|         { | ||||
|           importPath: '/data/user1/', | ||||
|           isValid: true, | ||||
|           message: undefined, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
| 
 | ||||
|     it('should detect when path does not exist', async () => { | ||||
| @ -1585,17 +1542,15 @@ describe(LibraryService.name, () => { | ||||
|         throw error; | ||||
|       }); | ||||
| 
 | ||||
|       const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { | ||||
|         importPaths: ['/data/user1/'], | ||||
|       await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ | ||||
|         importPaths: [ | ||||
|           { | ||||
|             importPath: '/data/user1/', | ||||
|             isValid: false, | ||||
|             message: 'Path does not exist (ENOENT)', | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
| 
 | ||||
|       expect(result.importPaths).toEqual([ | ||||
|         { | ||||
|           importPath: '/data/user1/', | ||||
|           isValid: false, | ||||
|           message: 'Path does not exist (ENOENT)', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
| 
 | ||||
|     it('should detect when path is not a directory', async () => { | ||||
| @ -1603,17 +1558,15 @@ describe(LibraryService.name, () => { | ||||
|         isDirectory: () => false, | ||||
|       } as Stats); | ||||
| 
 | ||||
|       const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { | ||||
|         importPaths: ['/data/user1/file'], | ||||
|       await expect(sut.validate('library-id', { importPaths: ['/data/user1/file'] })).resolves.toEqual({ | ||||
|         importPaths: [ | ||||
|           { | ||||
|             importPath: '/data/user1/file', | ||||
|             isValid: false, | ||||
|             message: 'Not a directory', | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
| 
 | ||||
|       expect(result.importPaths).toEqual([ | ||||
|         { | ||||
|           importPath: '/data/user1/file', | ||||
|           isValid: false, | ||||
|           message: 'Not a directory', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
| 
 | ||||
|     it('should return an unknown exception from stat', async () => { | ||||
| @ -1621,17 +1574,15 @@ describe(LibraryService.name, () => { | ||||
|         throw new Error('Unknown error'); | ||||
|       }); | ||||
| 
 | ||||
|       const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { | ||||
|         importPaths: ['/data/user1/'], | ||||
|       await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ | ||||
|         importPaths: [ | ||||
|           { | ||||
|             importPath: '/data/user1/', | ||||
|             isValid: false, | ||||
|             message: 'Error: Unknown error', | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
| 
 | ||||
|       expect(result.importPaths).toEqual([ | ||||
|         { | ||||
|           importPath: '/data/user1/', | ||||
|           isValid: false, | ||||
|           message: 'Error: Unknown error', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
| 
 | ||||
|     it('should detect when access rights are missing', async () => { | ||||
| @ -1641,17 +1592,15 @@ describe(LibraryService.name, () => { | ||||
| 
 | ||||
|       storageMock.checkFileExists.mockResolvedValue(false); | ||||
| 
 | ||||
|       const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { | ||||
|         importPaths: ['/data/user1/'], | ||||
|       await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ | ||||
|         importPaths: [ | ||||
|           { | ||||
|             importPath: '/data/user1/', | ||||
|             isValid: false, | ||||
|             message: 'Lacking read permission for folder', | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
| 
 | ||||
|       expect(result.importPaths).toEqual([ | ||||
|         { | ||||
|           importPath: '/data/user1/', | ||||
|           isValid: false, | ||||
|           message: 'Lacking read permission for folder', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
| 
 | ||||
|     it('should detect when import path is in immich media folder', async () => { | ||||
| @ -1659,26 +1608,26 @@ describe(LibraryService.name, () => { | ||||
|       const validImport = libraryStub.hasImmichPaths.importPaths[1]; | ||||
|       when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true); | ||||
| 
 | ||||
|       const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, { | ||||
|         importPaths: libraryStub.hasImmichPaths.importPaths, | ||||
|       await expect( | ||||
|         sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }), | ||||
|       ).resolves.toEqual({ | ||||
|         importPaths: [ | ||||
|           { | ||||
|             importPath: libraryStub.hasImmichPaths.importPaths[0], | ||||
|             isValid: false, | ||||
|             message: 'Cannot use media upload folder for external libraries', | ||||
|           }, | ||||
|           { | ||||
|             importPath: validImport, | ||||
|             isValid: true, | ||||
|           }, | ||||
|           { | ||||
|             importPath: libraryStub.hasImmichPaths.importPaths[2], | ||||
|             isValid: false, | ||||
|             message: 'Cannot use media upload folder for external libraries', | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
| 
 | ||||
|       expect(result.importPaths).toEqual([ | ||||
|         { | ||||
|           importPath: libraryStub.hasImmichPaths.importPaths[0], | ||||
|           isValid: false, | ||||
|           message: 'Cannot use media upload folder for external libraries', | ||||
|         }, | ||||
|         { | ||||
|           importPath: validImport, | ||||
|           isValid: true, | ||||
|         }, | ||||
|         { | ||||
|           importPath: libraryStub.hasImmichPaths.importPaths[2], | ||||
|           isValid: false, | ||||
|           message: 'Cannot use media upload folder for external libraries', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| @ -8,8 +8,7 @@ import { EventEmitter } from 'node:events'; | ||||
| import { Stats } from 'node:fs'; | ||||
| import path, { basename, parse } from 'node:path'; | ||||
| import picomatch from 'picomatch'; | ||||
| import { AccessCore, Permission } from '../access'; | ||||
| import { AuthDto } from '../auth'; | ||||
| import { AccessCore } from '../access'; | ||||
| import { mimeTypes } from '../domain.constant'; | ||||
| import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util'; | ||||
| import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; | ||||
| @ -226,24 +225,17 @@ export class LibraryService extends EventEmitter { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async getStatistics(auth: AuthDto, id: string): Promise<LibraryStatsResponseDto> { | ||||
|     await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); | ||||
| 
 | ||||
|   async getStatistics(id: string): Promise<LibraryStatsResponseDto> { | ||||
|     await this.findOrFail(id); | ||||
|     return this.repository.getStatistics(id); | ||||
|   } | ||||
| 
 | ||||
|   async getCount(auth: AuthDto): Promise<number> { | ||||
|     return this.repository.getCountForUser(auth.user.id); | ||||
|   } | ||||
| 
 | ||||
|   async get(auth: AuthDto, id: string): Promise<LibraryResponseDto> { | ||||
|     await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); | ||||
| 
 | ||||
|   async get(id: string): Promise<LibraryResponseDto> { | ||||
|     const library = await this.findOrFail(id); | ||||
|     return mapLibrary(library); | ||||
|   } | ||||
| 
 | ||||
|   async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise<LibraryResponseDto[]> { | ||||
|   async getAll(dto: SearchLibraryDto): Promise<LibraryResponseDto[]> { | ||||
|     const libraries = await this.repository.getAll(false, dto.type); | ||||
|     return libraries.map((library) => mapLibrary(library)); | ||||
|   } | ||||
| @ -257,7 +249,7 @@ export class LibraryService extends EventEmitter { | ||||
|     return JobStatus.SUCCESS; | ||||
|   } | ||||
| 
 | ||||
|   async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> { | ||||
|   async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> { | ||||
|     switch (dto.type) { | ||||
|       case LibraryType.EXTERNAL: { | ||||
|         if (!dto.name) { | ||||
| @ -282,14 +274,8 @@ export class LibraryService extends EventEmitter { | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     let ownerId = auth.user.id; | ||||
| 
 | ||||
|     if (dto.ownerId) { | ||||
|       ownerId = dto.ownerId; | ||||
|     } | ||||
| 
 | ||||
|     const library = await this.repository.create({ | ||||
|       ownerId, | ||||
|       ownerId: dto.ownerId, | ||||
|       name: dto.name, | ||||
|       type: dto.type, | ||||
|       importPaths: dto.importPaths ?? [], | ||||
| @ -297,7 +283,7 @@ export class LibraryService extends EventEmitter { | ||||
|       isVisible: dto.isVisible ?? true, | ||||
|     }); | ||||
| 
 | ||||
|     this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`); | ||||
|     this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`); | ||||
| 
 | ||||
|     if (dto.type === LibraryType.EXTERNAL) { | ||||
|       await this.watch(library.id); | ||||
| @ -364,29 +350,19 @@ export class LibraryService extends EventEmitter { | ||||
|     return validation; | ||||
|   } | ||||
| 
 | ||||
|   public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> { | ||||
|     await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); | ||||
| 
 | ||||
|     const response = new ValidateLibraryResponseDto(); | ||||
| 
 | ||||
|     if (dto.importPaths) { | ||||
|       response.importPaths = await Promise.all( | ||||
|         dto.importPaths.map(async (importPath) => { | ||||
|           return await this.validateImportPath(importPath); | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return response; | ||||
|   async validate(id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> { | ||||
|     const importPaths = await Promise.all( | ||||
|       (dto.importPaths || []).map((importPath) => this.validateImportPath(importPath)), | ||||
|     ); | ||||
|     return { importPaths }; | ||||
|   } | ||||
| 
 | ||||
|   async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> { | ||||
|     await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); | ||||
| 
 | ||||
|   async update(id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> { | ||||
|     await this.findOrFail(id); | ||||
|     const library = await this.repository.update({ id, ...dto }); | ||||
| 
 | ||||
|     if (dto.importPaths) { | ||||
|       const validation = await this.validate(auth, id, { importPaths: dto.importPaths }); | ||||
|       const validation = await this.validate(id, { importPaths: dto.importPaths }); | ||||
|       if (validation.importPaths) { | ||||
|         for (const path of validation.importPaths) { | ||||
|           if (!path.isValid) { | ||||
| @ -404,11 +380,9 @@ export class LibraryService extends EventEmitter { | ||||
|     return mapLibrary(library); | ||||
|   } | ||||
| 
 | ||||
|   async delete(auth: AuthDto, id: string) { | ||||
|     await this.access.requirePermission(auth, Permission.LIBRARY_DELETE, id); | ||||
| 
 | ||||
|   async delete(id: string) { | ||||
|     const library = await this.findOrFail(id); | ||||
|     const uploadCount = await this.repository.getUploadLibraryCount(auth.user.id); | ||||
|     const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId); | ||||
|     if (library.type === LibraryType.UPLOAD && uploadCount <= 1) { | ||||
|       throw new BadRequestException('Cannot delete the last upload library'); | ||||
|     } | ||||
| @ -565,11 +539,9 @@ export class LibraryService extends EventEmitter { | ||||
|     return JobStatus.SUCCESS; | ||||
|   } | ||||
| 
 | ||||
|   async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) { | ||||
|     await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); | ||||
| 
 | ||||
|     const library = await this.repository.get(id); | ||||
|     if (!library || library.type !== LibraryType.EXTERNAL) { | ||||
|   async queueScan(id: string, dto: ScanLibraryDto) { | ||||
|     const library = await this.findOrFail(id); | ||||
|     if (library.type !== LibraryType.EXTERNAL) { | ||||
|       throw new BadRequestException('Can only refresh external libraries'); | ||||
|     } | ||||
| 
 | ||||
| @ -583,16 +555,9 @@ export class LibraryService extends EventEmitter { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   async queueRemoveOffline(auth: AuthDto, id: string) { | ||||
|   async queueRemoveOffline(id: string) { | ||||
|     this.logger.verbose(`Removing offline files from library: ${id}`); | ||||
|     await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); | ||||
| 
 | ||||
|     await this.jobRepository.queue({ | ||||
|       name: JobName.LIBRARY_REMOVE_OFFLINE, | ||||
|       data: { | ||||
|         id, | ||||
|       }, | ||||
|     }); | ||||
|     await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); | ||||
|   } | ||||
| 
 | ||||
|   async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> { | ||||
|  | ||||
| @ -26,7 +26,6 @@ export interface IAccessRepository { | ||||
| 
 | ||||
|   library: { | ||||
|     checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>; | ||||
|     checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>; | ||||
|   }; | ||||
| 
 | ||||
|   timeline: { | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| import { | ||||
|   AuthDto, | ||||
|   CreateLibraryDto as CreateDto, | ||||
|   LibraryService, | ||||
|   LibraryStatsResponseDto, | ||||
| @ -12,7 +11,7 @@ import { | ||||
| } from '@app/domain'; | ||||
| import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { AdminRoute, Auth, Authenticated } from '../app.guard'; | ||||
| import { AdminRoute, Authenticated } from '../app.guard'; | ||||
| import { UUIDParamDto } from './dto/uuid-param.dto'; | ||||
| 
 | ||||
| @ApiTags('Library') | ||||
| @ -23,55 +22,52 @@ export class LibraryController { | ||||
|   constructor(private service: LibraryService) {} | ||||
| 
 | ||||
|   @Get() | ||||
|   getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise<ResponseDto[]> { | ||||
|     return this.service.getAll(auth, dto); | ||||
|   getAllLibraries(@Query() dto: SearchLibraryDto): Promise<ResponseDto[]> { | ||||
|     return this.service.getAll(dto); | ||||
|   } | ||||
| 
 | ||||
|   @Post() | ||||
|   createLibrary(@Auth() auth: AuthDto, @Body() dto: CreateDto): Promise<ResponseDto> { | ||||
|     return this.service.create(auth, dto); | ||||
|   createLibrary(@Body() dto: CreateDto): Promise<ResponseDto> { | ||||
|     return this.service.create(dto); | ||||
|   } | ||||
| 
 | ||||
|   @Put(':id') | ||||
|   updateLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> { | ||||
|     return this.service.update(auth, id, dto); | ||||
|   updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<ResponseDto> { | ||||
|     return this.service.update(id, dto); | ||||
|   } | ||||
| 
 | ||||
|   @Get(':id') | ||||
|   getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> { | ||||
|     return this.service.get(auth, id); | ||||
|   getLibrary(@Param() { id }: UUIDParamDto): Promise<ResponseDto> { | ||||
|     return this.service.get(id); | ||||
|   } | ||||
| 
 | ||||
|   @Post(':id/validate') | ||||
|   @HttpCode(200) | ||||
|   validate( | ||||
|     @Auth() auth: AuthDto, | ||||
|     @Param() { id }: UUIDParamDto, | ||||
|     @Body() dto: ValidateLibraryDto, | ||||
|   ): Promise<ValidateLibraryResponseDto> { | ||||
|     return this.service.validate(auth, id, dto); | ||||
|   // TODO: change endpoint to validate current settings instead
 | ||||
|   validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> { | ||||
|     return this.service.validate(id, dto); | ||||
|   } | ||||
| 
 | ||||
|   @Delete(':id') | ||||
|   @HttpCode(HttpStatus.NO_CONTENT) | ||||
|   deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { | ||||
|     return this.service.delete(auth, id); | ||||
|   deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> { | ||||
|     return this.service.delete(id); | ||||
|   } | ||||
| 
 | ||||
|   @Get(':id/statistics') | ||||
|   getLibraryStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> { | ||||
|     return this.service.getStatistics(auth, id); | ||||
|   getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> { | ||||
|     return this.service.getStatistics(id); | ||||
|   } | ||||
| 
 | ||||
|   @Post(':id/scan') | ||||
|   @HttpCode(HttpStatus.NO_CONTENT) | ||||
|   scanLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { | ||||
|     return this.service.queueScan(auth, id, dto); | ||||
|   scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { | ||||
|     return this.service.queueScan(id, dto); | ||||
|   } | ||||
| 
 | ||||
|   @Post(':id/removeOffline') | ||||
|   @HttpCode(HttpStatus.NO_CONTENT) | ||||
|   removeOfflineFiles(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { | ||||
|     return this.service.queueRemoveOffline(auth, id); | ||||
|   removeOfflineFiles(@Param() { id }: UUIDParamDto) { | ||||
|     return this.service.queueRemoveOffline(id); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -307,10 +307,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess { | ||||
| } | ||||
| 
 | ||||
| class LibraryAccess implements ILibraryAccess { | ||||
|   constructor( | ||||
|     private libraryRepository: Repository<LibraryEntity>, | ||||
|     private partnerRepository: Repository<PartnerEntity>, | ||||
|   ) {} | ||||
|   constructor(private libraryRepository: Repository<LibraryEntity>) {} | ||||
| 
 | ||||
|   @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) | ||||
|   @ChunkedSet({ paramIndex: 1 }) | ||||
| @ -329,22 +326,6 @@ class LibraryAccess implements ILibraryAccess { | ||||
|       }) | ||||
|       .then((libraries) => new Set(libraries.map((library) => library.id))); | ||||
|   } | ||||
| 
 | ||||
|   @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) | ||||
|   @ChunkedSet({ paramIndex: 1 }) | ||||
|   async checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> { | ||||
|     if (partnerIds.size === 0) { | ||||
|       return new Set(); | ||||
|     } | ||||
| 
 | ||||
|     return this.partnerRepository | ||||
|       .createQueryBuilder('partner') | ||||
|       .select('partner.sharedById') | ||||
|       .where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] }) | ||||
|       .andWhere('partner.sharedWithId = :userId', { userId }) | ||||
|       .getMany() | ||||
|       .then((partners) => new Set(partners.map((partner) => partner.sharedById))); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class TimelineAccess implements ITimelineAccess { | ||||
| @ -457,7 +438,7 @@ export class AccessRepository implements IAccessRepository { | ||||
|     this.album = new AlbumAccess(albumRepository, sharedLinkRepository); | ||||
|     this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); | ||||
|     this.authDevice = new AuthDeviceAccess(tokenRepository); | ||||
|     this.library = new LibraryAccess(libraryRepository, partnerRepository); | ||||
|     this.library = new LibraryAccess(libraryRepository); | ||||
|     this.person = new PersonAccess(assetFaceRepository, personRepository); | ||||
|     this.partner = new PartnerAccess(partnerRepository); | ||||
|     this.timeline = new TimelineAccess(partnerRepository); | ||||
|  | ||||
| @ -196,16 +196,6 @@ WHERE | ||||
|   ) | ||||
|   AND ("LibraryEntity"."deletedAt" IS NULL) | ||||
| 
 | ||||
| -- AccessRepository.library.checkPartnerAccess | ||||
| SELECT | ||||
|   "partner"."sharedById" AS "partner_sharedById", | ||||
|   "partner"."sharedWithId" AS "partner_sharedWithId" | ||||
| FROM | ||||
|   "partners" "partner" | ||||
| WHERE | ||||
|   "partner"."sharedById" IN ($1) | ||||
|   AND "partner"."sharedWithId" = $2 | ||||
| 
 | ||||
| -- AccessRepository.person.checkOwnerAccess | ||||
| SELECT | ||||
|   "PersonEntity"."id" AS "PersonEntity_id" | ||||
|  | ||||
| @ -42,7 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => | ||||
| 
 | ||||
|     library: { | ||||
|       checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), | ||||
|       checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), | ||||
|     }, | ||||
| 
 | ||||
|     timeline: { | ||||
|  | ||||
| @ -20,7 +20,7 @@ | ||||
| 
 | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     cancel: void; | ||||
|     submit: { ownerId: string | null }; | ||||
|     submit: { ownerId: string }; | ||||
|     delete: void; | ||||
|   }>(); | ||||
| 
 | ||||
|  | ||||
| @ -28,7 +28,6 @@ | ||||
|     removeOfflineFiles, | ||||
|     scanLibrary, | ||||
|     updateLibrary, | ||||
|     type CreateLibraryDto, | ||||
|     type LibraryResponseDto, | ||||
|     type LibraryStatsResponseDto, | ||||
|     type UserResponseDto, | ||||
| @ -117,14 +116,9 @@ | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const handleCreate = async (ownerId: string | null) => { | ||||
|   const handleCreate = async (ownerId: string) => { | ||||
|     try { | ||||
|       let createLibraryDto: CreateLibraryDto = { type: LibraryType.External }; | ||||
|       if (ownerId) { | ||||
|         createLibraryDto = { ...createLibraryDto, ownerId }; | ||||
|       } | ||||
| 
 | ||||
|       const createdLibrary = await createLibrary({ createLibraryDto }); | ||||
|       const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } }); | ||||
| 
 | ||||
|       notificationController.show({ | ||||
|         message: `Created library: ${createdLibrary.name}`, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user