mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	Merge branch 'main' of github.com:immich-app/immich
This commit is contained in:
		
							
								
								
									
										2
									
								
								mobile/openapi/doc/AlbumApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/AlbumApi.md
									
									
									
										generated
									
									
									
								
							| @@ -477,7 +477,7 @@ import 'package:openapi/api.dart'; | ||||
| 
 | ||||
| final api_instance = AlbumApi(); | ||||
| final shared = true; // bool |  | ||||
| final assetId = assetId_example; // String | Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums | ||||
| final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getAllAlbums(shared, assetId); | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { AlbumEntity, AssetEntity, dataSource, UserEntity } from '@app/infra'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Repository, Not, IsNull, FindManyOptions } from 'typeorm'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import { AddAssetsDto } from './dto/add-assets.dto'; | ||||
| import { AddUsersDto } from './dto/add-users.dto'; | ||||
| import { CreateAlbumDto } from './dto/create-album.dto'; | ||||
| import { GetAlbumsDto } from './dto/get-albums.dto'; | ||||
| import { RemoveAssetsDto } from './dto/remove-assets.dto'; | ||||
| import { UpdateAlbumDto } from './dto/update-album.dto'; | ||||
| import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; | ||||
| @@ -13,8 +12,6 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; | ||||
|  | ||||
| export interface IAlbumRepository { | ||||
|   create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>; | ||||
|   getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>; | ||||
|   getPublicSharingList(ownerId: string): Promise<AlbumEntity[]>; | ||||
|   get(albumId: string): Promise<AlbumEntity | null>; | ||||
|   delete(album: AlbumEntity): Promise<void>; | ||||
|   addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>; | ||||
| @@ -23,7 +20,6 @@ export interface IAlbumRepository { | ||||
|   addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>; | ||||
|   updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>; | ||||
|   updateThumbnails(): Promise<number | undefined>; | ||||
|   getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>; | ||||
|   getCountByUserId(userId: string): Promise<AlbumCountResponseDto>; | ||||
|   getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>; | ||||
| } | ||||
| @@ -40,22 +36,6 @@ export class AlbumRepository implements IAlbumRepository { | ||||
|     private assetRepository: Repository<AssetEntity>, | ||||
|   ) {} | ||||
|  | ||||
|   async getPublicSharingList(ownerId: string): Promise<AlbumEntity[]> { | ||||
|     return this.albumRepository.find({ | ||||
|       relations: { | ||||
|         sharedLinks: true, | ||||
|         assets: true, | ||||
|         owner: true, | ||||
|       }, | ||||
|       where: { | ||||
|         ownerId, | ||||
|         sharedLinks: { | ||||
|           id: Not(IsNull()), | ||||
|         }, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> { | ||||
|     const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] }); | ||||
|     const sharedAlbums = await this.albumRepository.count({ where: { sharedUsers: { id: userId } } }); | ||||
| @@ -77,59 +57,6 @@ export class AlbumRepository implements IAlbumRepository { | ||||
|     return this.get(album.id) as Promise<AlbumEntity>; | ||||
|   } | ||||
|  | ||||
|   async getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> { | ||||
|     const filteringByShared = typeof getAlbumsDto.shared == 'boolean'; | ||||
|     const userId = ownerId; | ||||
|  | ||||
|     const queryProperties: FindManyOptions<AlbumEntity> = { | ||||
|       relations: { sharedUsers: true, assets: true, sharedLinks: true, owner: true }, | ||||
|       select: { assets: { id: true } }, | ||||
|       order: { createdAt: 'DESC' }, | ||||
|     }; | ||||
|  | ||||
|     let albumsQuery: Promise<AlbumEntity[]>; | ||||
|  | ||||
|     /** | ||||
|      * `shared` boolean usage | ||||
|      * true = shared with me, and my albums that are shared | ||||
|      * false = my albums that are not shared | ||||
|      * undefined = all my albums | ||||
|      */ | ||||
|     if (filteringByShared) { | ||||
|       if (getAlbumsDto.shared) { | ||||
|         // shared albums | ||||
|         albumsQuery = this.albumRepository.find({ | ||||
|           where: [{ sharedUsers: { id: userId } }, { ownerId: userId, sharedUsers: { id: Not(IsNull()) } }], | ||||
|           ...queryProperties, | ||||
|         }); | ||||
|       } else { | ||||
|         // owned, not shared albums | ||||
|         albumsQuery = this.albumRepository.find({ | ||||
|           where: { ownerId: userId, sharedUsers: { id: IsNull() } }, | ||||
|           ...queryProperties, | ||||
|         }); | ||||
|       } | ||||
|     } else { | ||||
|       // owned | ||||
|       albumsQuery = this.albumRepository.find({ | ||||
|         where: { ownerId: userId }, | ||||
|         ...queryProperties, | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     return albumsQuery; | ||||
|   } | ||||
|  | ||||
|   async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> { | ||||
|     const albums = await this.albumRepository.find({ | ||||
|       where: { ownerId: userId }, | ||||
|       relations: { owner: true, assets: true, sharedUsers: true }, | ||||
|       order: { assets: { fileCreatedAt: 'ASC' } }, | ||||
|     }); | ||||
|  | ||||
|     return albums.filter((album) => album.assets.some((asset) => asset.id === assetId)); | ||||
|   } | ||||
|  | ||||
|   async get(albumId: string): Promise<AlbumEntity | null> { | ||||
|     return this.albumRepository.findOne({ | ||||
|       where: { id: albumId }, | ||||
|   | ||||
| @@ -21,7 +21,6 @@ import { AddAssetsDto } from './dto/add-assets.dto'; | ||||
| import { AddUsersDto } from './dto/add-users.dto'; | ||||
| import { RemoveAssetsDto } from './dto/remove-assets.dto'; | ||||
| import { UpdateAlbumDto } from './dto/update-album.dto'; | ||||
| import { GetAlbumsDto } from './dto/get-albums.dto'; | ||||
| import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; | ||||
| import { AlbumResponseDto } from '@app/domain'; | ||||
| import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; | ||||
| @@ -74,15 +73,6 @@ export class AlbumController { | ||||
|     return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId); | ||||
|   } | ||||
|  | ||||
|   @Authenticated() | ||||
|   @Get() | ||||
|   async getAllAlbums( | ||||
|     @GetAuthUser() authUser: AuthUserDto, | ||||
|     @Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto, | ||||
|   ) { | ||||
|     return this.albumService.getAllAlbums(authUser, query); | ||||
|   } | ||||
|  | ||||
|   @Authenticated({ isShared: true }) | ||||
|   @Get('/:albumId') | ||||
|   async getAlbumInfo( | ||||
|   | ||||
| @@ -1,14 +1,13 @@ | ||||
| import { AlbumService } from './album.service'; | ||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||
| import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; | ||||
| import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra'; | ||||
| import { AlbumEntity, UserEntity } from '@app/infra'; | ||||
| import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain'; | ||||
| import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; | ||||
| import { IAlbumRepository } from './album-repository'; | ||||
| import { DownloadService } from '../../modules/download/download.service'; | ||||
| import { ISharedLinkRepository } from '@app/domain'; | ||||
| import { | ||||
|   assetEntityStub, | ||||
|   newCryptoRepositoryMock, | ||||
|   newJobRepositoryMock, | ||||
|   newSharedLinkRepositoryMock, | ||||
| @@ -119,18 +118,15 @@ describe('Album service', () => { | ||||
|  | ||||
|   beforeAll(() => { | ||||
|     albumRepositoryMock = { | ||||
|       getPublicSharingList: jest.fn(), | ||||
|       addAssets: jest.fn(), | ||||
|       addSharedUsers: jest.fn(), | ||||
|       create: jest.fn(), | ||||
|       delete: jest.fn(), | ||||
|       get: jest.fn(), | ||||
|       getList: jest.fn(), | ||||
|       removeAssets: jest.fn(), | ||||
|       removeUser: jest.fn(), | ||||
|       updateAlbum: jest.fn(), | ||||
|       updateThumbnails: jest.fn(), | ||||
|       getListByAssetId: jest.fn(), | ||||
|       getCountByUserId: jest.fn(), | ||||
|       getSharedWithUserAlbumCount: jest.fn(), | ||||
|     }; | ||||
| @@ -166,19 +162,6 @@ describe('Album service', () => { | ||||
|     expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [albumEntity.id] } }); | ||||
|   }); | ||||
|  | ||||
|   it('gets list of albums for auth user', async () => { | ||||
|     const ownedAlbum = _getOwnedAlbum(); | ||||
|     const ownedSharedAlbum = _getOwnedSharedAlbum(); | ||||
|     const sharedWithMeAlbum = _getSharedWithAuthUserAlbum(); | ||||
|     const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum]; | ||||
|  | ||||
|     albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums)); | ||||
|  | ||||
|     const result = await sut.getAllAlbums(authUser, {}); | ||||
|     expect(result).toHaveLength(1); | ||||
|     expect(result[0].id).toEqual(ownedAlbum.id); | ||||
|   }); | ||||
|  | ||||
|   it('gets an owned album', async () => { | ||||
|     const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; | ||||
|  | ||||
| @@ -474,76 +457,4 @@ describe('Album service', () => { | ||||
|       ), | ||||
|     ).rejects.toBeInstanceOf(ForbiddenException); | ||||
|   }); | ||||
|  | ||||
|   it('counts assets correctly', async () => { | ||||
|     const albumEntity = new AlbumEntity(); | ||||
|  | ||||
|     albumEntity.ownerId = authUser.id; | ||||
|     albumEntity.owner = albumOwner; | ||||
|     albumEntity.id = albumId; | ||||
|     albumEntity.albumName = 'name'; | ||||
|     albumEntity.createdAt = 'date'; | ||||
|     albumEntity.sharedUsers = []; | ||||
|     albumEntity.assets = [ | ||||
|       { | ||||
|         ...assetEntityStub.image, | ||||
|         id: '3', | ||||
|       }, | ||||
|     ]; | ||||
|     albumEntity.albumThumbnailAssetId = null; | ||||
|  | ||||
|     albumRepositoryMock.getList.mockImplementation(() => Promise.resolve([albumEntity])); | ||||
|  | ||||
|     const result = await sut.getAllAlbums(authUser, {}); | ||||
|  | ||||
|     expect(result).toHaveLength(1); | ||||
|     expect(result[0].assetCount).toEqual(1); | ||||
|   }); | ||||
|  | ||||
|   it('updates the album thumbnail by listing all albums', async () => { | ||||
|     const albumEntity = _getOwnedAlbum(); | ||||
|     const assetEntity = new AssetEntity(); | ||||
|     const newThumbnailAsset = new AssetEntity(); | ||||
|     newThumbnailAsset.id = 'e5e65c02-b889-4f3c-afe1-a39a96d578ed'; | ||||
|  | ||||
|     albumEntity.albumThumbnailAssetId = 'nonexistent'; | ||||
|     assetEntity.id = newThumbnailAsset.id; | ||||
|     albumEntity.assets = [assetEntity]; | ||||
|     albumRepositoryMock.getList.mockImplementation(async () => [albumEntity]); | ||||
|     albumRepositoryMock.updateThumbnails.mockImplementation(async () => { | ||||
|       albumEntity.albumThumbnailAsset = newThumbnailAsset; | ||||
|       albumEntity.albumThumbnailAssetId = newThumbnailAsset.id; | ||||
|  | ||||
|       return 1; | ||||
|     }); | ||||
|  | ||||
|     const result = await sut.getAllAlbums(authUser, {}); | ||||
|  | ||||
|     expect(result).toHaveLength(1); | ||||
|     expect(result[0].albumThumbnailAssetId).toEqual(newThumbnailAsset.id); | ||||
|     expect(albumRepositoryMock.getList).toHaveBeenCalledTimes(1); | ||||
|     expect(albumRepositoryMock.updateThumbnails).toHaveBeenCalledTimes(1); | ||||
|     expect(albumRepositoryMock.getList).toHaveBeenCalledWith(albumEntity.ownerId, {}); | ||||
|   }); | ||||
|  | ||||
|   it('removes the thumbnail for an empty album', async () => { | ||||
|     const albumEntity = _getOwnedAlbum(); | ||||
|  | ||||
|     albumEntity.albumThumbnailAssetId = 'e5e65c02-b889-4f3c-afe1-a39a96d578ed'; | ||||
|     albumRepositoryMock.getList.mockImplementation(async () => [albumEntity]); | ||||
|     albumRepositoryMock.updateThumbnails.mockImplementation(async () => { | ||||
|       albumEntity.albumThumbnailAsset = null; | ||||
|       albumEntity.albumThumbnailAssetId = null; | ||||
|  | ||||
|       return 1; | ||||
|     }); | ||||
|  | ||||
|     const result = await sut.getAllAlbums(authUser, {}); | ||||
|  | ||||
|     expect(result).toHaveLength(1); | ||||
|     expect(result[0].albumThumbnailAssetId).toBeNull(); | ||||
|     expect(albumRepositoryMock.getList).toHaveBeenCalledTimes(1); | ||||
|     expect(albumRepositoryMock.updateThumbnails).toHaveBeenCalledTimes(1); | ||||
|     expect(albumRepositoryMock.getList).toHaveBeenCalledWith(albumEntity.ownerId, {}); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -5,8 +5,7 @@ import { AlbumEntity, SharedLinkType } from '@app/infra'; | ||||
| import { AddUsersDto } from './dto/add-users.dto'; | ||||
| import { RemoveAssetsDto } from './dto/remove-assets.dto'; | ||||
| import { UpdateAlbumDto } from './dto/update-album.dto'; | ||||
| import { GetAlbumsDto } from './dto/get-albums.dto'; | ||||
| import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain'; | ||||
| import { AlbumResponseDto, IJobRepository, JobName, mapAlbum } from '@app/domain'; | ||||
| import { IAlbumRepository } from './album-repository'; | ||||
| import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; | ||||
| import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; | ||||
| @@ -15,7 +14,6 @@ import { DownloadService } from '../../modules/download/download.service'; | ||||
| import { DownloadDto } from '../asset/dto/download-library.dto'; | ||||
| import { ShareCore, ISharedLinkRepository, mapSharedLink, SharedLinkResponseDto, ICryptoRepository } from '@app/domain'; | ||||
| import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto'; | ||||
| import _ from 'lodash'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AlbumService { | ||||
| @@ -63,31 +61,6 @@ export class AlbumService { | ||||
|     return mapAlbum(albumEntity); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get all shared album, including owned and shared one. | ||||
|    * @param authUser AuthUserDto | ||||
|    * @returns All Shared Album And Its Members | ||||
|    */ | ||||
|   async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> { | ||||
|     await this.albumRepository.updateThumbnails(); | ||||
|  | ||||
|     let albums: AlbumEntity[]; | ||||
|     if (typeof getAlbumsDto.assetId === 'string') { | ||||
|       albums = await this.albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId); | ||||
|     } else { | ||||
|       albums = await this.albumRepository.getList(authUser.id, getAlbumsDto); | ||||
|  | ||||
|       if (getAlbumsDto.shared) { | ||||
|         const publicSharingAlbums = await this.albumRepository.getPublicSharingList(authUser.id); | ||||
|         albums = [...albums, ...publicSharingAlbums]; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     albums = _.uniqBy(albums, (album) => album.id); | ||||
|  | ||||
|     return albums.map((album) => mapAlbumExcludeAssetInfo(album)); | ||||
|   } | ||||
|  | ||||
|   async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> { | ||||
|     const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); | ||||
|     return mapAlbum(album); | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import { TagModule } from './api-v1/tag/tag.module'; | ||||
| import { DomainModule, SearchService } from '@app/domain'; | ||||
| import { InfraModule } from '@app/infra'; | ||||
| import { | ||||
|   AlbumController, | ||||
|   APIKeyController, | ||||
|   AuthController, | ||||
|   DeviceInfoController, | ||||
| @@ -35,6 +36,7 @@ import { AppCronJobs } from './app.cron-jobs'; | ||||
|   ], | ||||
|   controllers: [ | ||||
|     AppController, | ||||
|     AlbumController, | ||||
|     APIKeyController, | ||||
|     AuthController, | ||||
|     DeviceInfoController, | ||||
|   | ||||
							
								
								
									
										21
									
								
								server/apps/immich/src/controllers/album.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								server/apps/immich/src/controllers/album.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import { AlbumService, AuthUserDto } from '@app/domain'; | ||||
| import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; | ||||
| import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { GetAuthUser } from '../decorators/auth-user.decorator'; | ||||
| import { Authenticated } from '../decorators/authenticated.decorator'; | ||||
|  | ||||
| @ApiTags('Album') | ||||
| @Controller('album') | ||||
| @Authenticated() | ||||
| export class AlbumController { | ||||
|   constructor(private service: AlbumService) {} | ||||
|  | ||||
|   @Get() | ||||
|   async getAllAlbums( | ||||
|     @GetAuthUser() authUser: AuthUserDto, | ||||
|     @Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto, | ||||
|   ) { | ||||
|     return this.service.getAllAlbums(authUser, query); | ||||
|   } | ||||
| } | ||||
| @@ -1,3 +1,4 @@ | ||||
| export * from './album.controller'; | ||||
| export * from './api-key.controller'; | ||||
| export * from './auth.controller'; | ||||
| export * from './device-info.controller'; | ||||
|   | ||||
| @@ -3,13 +3,22 @@ import { INestApplication } from '@nestjs/common'; | ||||
| import request from 'supertest'; | ||||
| import { clearDb, getAuthUser, authCustom } from './test-utils'; | ||||
| import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto'; | ||||
| import { CreateAlbumShareLinkDto } from '../src/api-v1/album/dto/create-album-shared-link.dto'; | ||||
| import { AuthUserDto } from '../src/decorators/auth-user.decorator'; | ||||
| import { AuthService, UserService } from '@app/domain'; | ||||
| import { AlbumResponseDto, AuthService, SharedLinkResponseDto, UserService } from '@app/domain'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { AppModule } from '../src/app.module'; | ||||
|  | ||||
| function _createAlbum(app: INestApplication, data: CreateAlbumDto) { | ||||
|   return request(app.getHttpServer()).post('/album').send(data); | ||||
| async function _createAlbum(app: INestApplication, data: CreateAlbumDto) { | ||||
|   const res = await request(app.getHttpServer()).post('/album').send(data); | ||||
|   expect(res.status).toEqual(201); | ||||
|   return res.body as AlbumResponseDto; | ||||
| } | ||||
|  | ||||
| async function _createAlbumSharedLink(app: INestApplication, data: CreateAlbumShareLinkDto) { | ||||
|   const res = await request(app.getHttpServer()).post('/album/create-shared-link').send(data); | ||||
|   expect(res.status).toEqual(201); | ||||
|   return res.body as SharedLinkResponseDto; | ||||
| } | ||||
|  | ||||
| describe('Album', () => { | ||||
| @@ -57,30 +66,38 @@ describe('Album', () => { | ||||
|       await app.close(); | ||||
|     }); | ||||
|  | ||||
|     // TODO - Until someone figure out how to passed in a logged in user to the request. | ||||
|     // describe('with empty DB', () => { | ||||
|     //   it('creates an album', async () => { | ||||
|     //     const data: CreateAlbumDto = { | ||||
|     //       albumName: 'first albbum', | ||||
|     //     }; | ||||
|     describe('with empty DB', () => { | ||||
|       it('rejects invalid shared param', async () => { | ||||
|         const { status } = await request(app.getHttpServer()).get('/album?shared=invalid'); | ||||
|         expect(status).toEqual(400); | ||||
|       }); | ||||
|  | ||||
|     //     const { status, body } = await _createAlbum(app, data); | ||||
|       it('rejects invalid assetId param', async () => { | ||||
|         const { status } = await request(app.getHttpServer()).get('/album?assetId=invalid'); | ||||
|         expect(status).toEqual(400); | ||||
|       }); | ||||
|  | ||||
|     //     expect(status).toEqual(201); | ||||
|  | ||||
|     //     expect(body).toEqual( | ||||
|     //       expect.objectContaining({ | ||||
|     //         ownerId: authUser.id, | ||||
|     //         albumName: data.albumName, | ||||
|     //       }), | ||||
|     //     ); | ||||
|     //   }); | ||||
|     // }); | ||||
|       // TODO - Until someone figure out how to passed in a logged in user to the request. | ||||
|       //   it('creates an album', async () => { | ||||
|       //     const data: CreateAlbumDto = { | ||||
|       //       albumName: 'first albbum', | ||||
|       //     }; | ||||
|       //     const body = await _createAlbum(app, data); | ||||
|       //     expect(body).toEqual( | ||||
|       //       expect.objectContaining({ | ||||
|       //         ownerId: authUser.id, | ||||
|       //         albumName: data.albumName, | ||||
|       //       }), | ||||
|       //     ); | ||||
|       //   }); | ||||
|     }); | ||||
|  | ||||
|     describe('with albums in DB', () => { | ||||
|       const userOneShared = 'userOneShared'; | ||||
|       const userOneSharedUser = 'userOneSharedUser'; | ||||
|       const userOneSharedLink = 'userOneSharedLink'; | ||||
|       const userOneNotShared = 'userOneNotShared'; | ||||
|       const userTwoShared = 'userTwoShared'; | ||||
|       const userTwoSharedUser = 'userTwoSharedUser'; | ||||
|       const userTwoSharedLink = 'userTwoSharedLink'; | ||||
|       const userTwoNotShared = 'userTwoNotShared'; | ||||
|       let userOne: AuthUserDto; | ||||
|       let userTwo: AuthUserDto; | ||||
| @@ -104,16 +121,26 @@ describe('Album', () => { | ||||
|  | ||||
|         // add user one albums | ||||
|         authUser = userOne; | ||||
|         await Promise.all([ | ||||
|           _createAlbum(app, { albumName: userOneShared, sharedWithUserIds: [userTwo.id] }), | ||||
|         const userOneAlbums = await Promise.all([ | ||||
|           _createAlbum(app, { albumName: userOneSharedUser, sharedWithUserIds: [userTwo.id] }), | ||||
|           _createAlbum(app, { albumName: userOneSharedLink }), | ||||
|           _createAlbum(app, { albumName: userOneNotShared }), | ||||
|         ]); | ||||
|  | ||||
|         // add shared link to userOneSharedLink album | ||||
|         await _createAlbumSharedLink(app, { albumId: userOneAlbums[1].id }); | ||||
|  | ||||
|         // add user two albums | ||||
|         authUser = userTwo; | ||||
|         await Promise.all([ | ||||
|           _createAlbum(app, { albumName: userTwoShared, sharedWithUserIds: [userOne.id] }), | ||||
|         const userTwoAlbums = await Promise.all([ | ||||
|           _createAlbum(app, { albumName: userTwoSharedUser, sharedWithUserIds: [userOne.id] }), | ||||
|           _createAlbum(app, { albumName: userTwoSharedLink }), | ||||
|           _createAlbum(app, { albumName: userTwoNotShared }), | ||||
|         ]); | ||||
|  | ||||
|         // add shared link to userTwoSharedLink album | ||||
|         await _createAlbumSharedLink(app, { albumId: userTwoAlbums[1].id }); | ||||
|  | ||||
|         // set user one as authed for next requests | ||||
|         authUser = userOne; | ||||
|       }); | ||||
| @@ -125,10 +152,11 @@ describe('Album', () => { | ||||
|       it('returns the album collection including owned and shared', async () => { | ||||
|         const { status, body } = await request(app.getHttpServer()).get('/album'); | ||||
|         expect(status).toEqual(200); | ||||
|         expect(body).toHaveLength(2); | ||||
|         expect(body).toHaveLength(3); | ||||
|         expect(body).toEqual( | ||||
|           expect.arrayContaining([ | ||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }), | ||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedUser, shared: true }), | ||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedLink, shared: true }), | ||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }), | ||||
|           ]), | ||||
|         ); | ||||
| @@ -137,11 +165,12 @@ describe('Album', () => { | ||||
|       it('returns the album collection filtered by shared', async () => { | ||||
|         const { status, body } = await request(app.getHttpServer()).get('/album?shared=true'); | ||||
|         expect(status).toEqual(200); | ||||
|         expect(body).toHaveLength(2); | ||||
|         expect(body).toHaveLength(3); | ||||
|         expect(body).toEqual( | ||||
|           expect.arrayContaining([ | ||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }), | ||||
|             expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }), | ||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedUser, shared: true }), | ||||
|             expect.objectContaining({ ownerId: userOne.id, albumName: userOneSharedLink, shared: true }), | ||||
|             expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoSharedUser, shared: true }), | ||||
|           ]), | ||||
|         ); | ||||
|       }); | ||||
| @@ -156,6 +185,33 @@ describe('Album', () => { | ||||
|           ]), | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|       // TODO: Add asset to album and test if it returns correctly. | ||||
|       it('returns the album collection filtered by assetId', async () => { | ||||
|         const { status, body } = await request(app.getHttpServer()).get( | ||||
|           '/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790', | ||||
|         ); | ||||
|         expect(status).toEqual(200); | ||||
|         expect(body).toHaveLength(0); | ||||
|       }); | ||||
|  | ||||
|       // TODO: Add asset to album and test if it returns correctly. | ||||
|       it('returns the album collection filtered by assetId and ignores shared=true', async () => { | ||||
|         const { status, body } = await request(app.getHttpServer()).get( | ||||
|           '/album?shared=true&assetId=ecb120db-45a2-4a65-9293-51476f0d8790', | ||||
|         ); | ||||
|         expect(status).toEqual(200); | ||||
|         expect(body).toHaveLength(0); | ||||
|       }); | ||||
|  | ||||
|       // TODO: Add asset to album and test if it returns correctly. | ||||
|       it('returns the album collection filtered by assetId and ignores shared=false', async () => { | ||||
|         const { status, body } = await request(app.getHttpServer()).get( | ||||
|           '/album?shared=false&assetId=ecb120db-45a2-4a65-9293-51476f0d8790', | ||||
|         ); | ||||
|         expect(status).toEqual(200); | ||||
|         expect(body).toHaveLength(0); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -2,8 +2,19 @@ import { AlbumEntity } from '@app/infra/db/entities'; | ||||
|  | ||||
| export const IAlbumRepository = 'IAlbumRepository'; | ||||
|  | ||||
| export interface AlbumAssetCount { | ||||
|   albumId: string; | ||||
|   assetCount: number; | ||||
| } | ||||
|  | ||||
| export interface IAlbumRepository { | ||||
|   getByIds(ids: string[]): Promise<AlbumEntity[]>; | ||||
|   getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>; | ||||
|   getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>; | ||||
|   getInvalidThumbnail(): Promise<string[]>; | ||||
|   getOwned(ownerId: string): Promise<AlbumEntity[]>; | ||||
|   getShared(ownerId: string): Promise<AlbumEntity[]>; | ||||
|   getNotShared(ownerId: string): Promise<AlbumEntity[]>; | ||||
|   deleteAll(userId: string): Promise<void>; | ||||
|   getAll(): Promise<AlbumEntity[]>; | ||||
|   save(album: Partial<AlbumEntity>): Promise<AlbumEntity>; | ||||
|   | ||||
							
								
								
									
										114
									
								
								server/libs/domain/src/album/album.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								server/libs/domain/src/album/album.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| import { albumStub, authStub, newAlbumRepositoryMock, newAssetRepositoryMock } from '../../test'; | ||||
| import { IAssetRepository } from '../asset'; | ||||
| import { IAlbumRepository } from './album.repository'; | ||||
| import { AlbumService } from './album.service'; | ||||
|  | ||||
| describe(AlbumService.name, () => { | ||||
|   let sut: AlbumService; | ||||
|   let albumMock: jest.Mocked<IAlbumRepository>; | ||||
|   let assetMock: jest.Mocked<IAssetRepository>; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     albumMock = newAlbumRepositoryMock(); | ||||
|     assetMock = newAssetRepositoryMock(); | ||||
|  | ||||
|     sut = new AlbumService(albumMock, assetMock); | ||||
|   }); | ||||
|  | ||||
|   it('should work', () => { | ||||
|     expect(sut).toBeDefined(); | ||||
|   }); | ||||
|  | ||||
|   describe('get list of albums', () => { | ||||
|     it('gets list of albums for auth user', async () => { | ||||
|       albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); | ||||
|       albumMock.getAssetCountForIds.mockResolvedValue([ | ||||
|         { albumId: albumStub.empty.id, assetCount: 0 }, | ||||
|         { albumId: albumStub.sharedWithUser.id, assetCount: 0 }, | ||||
|       ]); | ||||
|       albumMock.getInvalidThumbnail.mockResolvedValue([]); | ||||
|  | ||||
|       const result = await sut.getAllAlbums(authStub.admin, {}); | ||||
|       expect(result).toHaveLength(2); | ||||
|       expect(result[0].id).toEqual(albumStub.empty.id); | ||||
|       expect(result[1].id).toEqual(albumStub.sharedWithUser.id); | ||||
|     }); | ||||
|  | ||||
|     it('gets list of albums that have a specific asset', async () => { | ||||
|       albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]); | ||||
|       albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]); | ||||
|       albumMock.getInvalidThumbnail.mockResolvedValue([]); | ||||
|  | ||||
|       const result = await sut.getAllAlbums(authStub.admin, { assetId: albumStub.oneAsset.id }); | ||||
|       expect(result).toHaveLength(1); | ||||
|       expect(result[0].id).toEqual(albumStub.oneAsset.id); | ||||
|       expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|  | ||||
|     it('gets list of albums that are shared', async () => { | ||||
|       albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); | ||||
|       albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]); | ||||
|       albumMock.getInvalidThumbnail.mockResolvedValue([]); | ||||
|  | ||||
|       const result = await sut.getAllAlbums(authStub.admin, { shared: true }); | ||||
|       expect(result).toHaveLength(1); | ||||
|       expect(result[0].id).toEqual(albumStub.sharedWithUser.id); | ||||
|       expect(albumMock.getShared).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|  | ||||
|     it('gets list of albums that are NOT shared', async () => { | ||||
|       albumMock.getNotShared.mockResolvedValue([albumStub.empty]); | ||||
|       albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]); | ||||
|       albumMock.getInvalidThumbnail.mockResolvedValue([]); | ||||
|  | ||||
|       const result = await sut.getAllAlbums(authStub.admin, { shared: false }); | ||||
|       expect(result).toHaveLength(1); | ||||
|       expect(result[0].id).toEqual(albumStub.empty.id); | ||||
|       expect(albumMock.getNotShared).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('counts assets correctly', async () => { | ||||
|     albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]); | ||||
|     albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]); | ||||
|     albumMock.getInvalidThumbnail.mockResolvedValue([]); | ||||
|  | ||||
|     const result = await sut.getAllAlbums(authStub.admin, {}); | ||||
|  | ||||
|     expect(result).toHaveLength(1); | ||||
|     expect(result[0].assetCount).toEqual(1); | ||||
|     expect(albumMock.getOwned).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
|  | ||||
|   it('updates the album thumbnail by listing all albums', async () => { | ||||
|     albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]); | ||||
|     albumMock.getAssetCountForIds.mockResolvedValue([ | ||||
|       { albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 }, | ||||
|     ]); | ||||
|     albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]); | ||||
|     albumMock.save.mockResolvedValue(albumStub.oneAssetValidThumbnail); | ||||
|     assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]); | ||||
|  | ||||
|     const result = await sut.getAllAlbums(authStub.admin, {}); | ||||
|  | ||||
|     expect(result).toHaveLength(1); | ||||
|     expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1); | ||||
|     expect(albumMock.save).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
|  | ||||
|   it('removes the thumbnail for an empty album', async () => { | ||||
|     albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]); | ||||
|     albumMock.getAssetCountForIds.mockResolvedValue([ | ||||
|       { albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 }, | ||||
|     ]); | ||||
|     albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]); | ||||
|     albumMock.save.mockResolvedValue(albumStub.emptyWithValidThumbnail); | ||||
|     assetMock.getFirstAssetForAlbumId.mockResolvedValue(null); | ||||
|  | ||||
|     const result = await sut.getAllAlbums(authStub.admin, {}); | ||||
|  | ||||
|     expect(result).toHaveLength(1); | ||||
|     expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1); | ||||
|     expect(albumMock.save).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										58
									
								
								server/libs/domain/src/album/album.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								server/libs/domain/src/album/album.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| import { AlbumEntity } from '@app/infra'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { IAssetRepository } from '../asset'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { IAlbumRepository } from './album.repository'; | ||||
| import { GetAlbumsDto } from './dto/get-albums.dto'; | ||||
| import { AlbumResponseDto } from './response-dto'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AlbumService { | ||||
|   constructor( | ||||
|     @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, | ||||
|     @Inject(IAssetRepository) private assetRepository: IAssetRepository, | ||||
|   ) {} | ||||
|  | ||||
|   async getAllAlbums({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { | ||||
|     await this.updateInvalidThumbnails(); | ||||
|  | ||||
|     let albums: AlbumEntity[]; | ||||
|     if (assetId) { | ||||
|       albums = await this.albumRepository.getByAssetId(ownerId, assetId); | ||||
|     } else if (shared === true) { | ||||
|       albums = await this.albumRepository.getShared(ownerId); | ||||
|     } else if (shared === false) { | ||||
|       albums = await this.albumRepository.getNotShared(ownerId); | ||||
|     } else { | ||||
|       albums = await this.albumRepository.getOwned(ownerId); | ||||
|     } | ||||
|  | ||||
|     // Get asset count for each album. Then map the result to an object: | ||||
|     // { [albumId]: assetCount } | ||||
|     const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id)); | ||||
|     const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record<string, number>, { albumId, assetCount }) => { | ||||
|       obj[albumId] = assetCount; | ||||
|       return obj; | ||||
|     }, {}); | ||||
|  | ||||
|     return albums.map((album) => { | ||||
|       return { | ||||
|         ...album, | ||||
|         sharedLinks: undefined, // Don't return shared links | ||||
|         shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0, | ||||
|         assetCount: albumsAssetCountObj[album.id], | ||||
|       } as AlbumResponseDto; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async updateInvalidThumbnails(): Promise<number> { | ||||
|     const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); | ||||
|  | ||||
|     for (const albumId of invalidAlbumIds) { | ||||
|       const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId); | ||||
|       await this.albumRepository.save({ id: albumId, albumThumbnailAsset: newThumbnail }); | ||||
|     } | ||||
|  | ||||
|     return invalidAlbumIds.length; | ||||
|   } | ||||
| } | ||||
| @@ -1,11 +1,13 @@ | ||||
| import { Transform } from 'class-transformer'; | ||||
| import { IsBoolean, IsOptional } from 'class-validator'; | ||||
| import { toBoolean } from '../../../utils/transform.util'; | ||||
| import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; | ||||
| import { toBoolean } from 'apps/immich/src/utils/transform.util'; | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| 
 | ||||
| export class GetAlbumsDto { | ||||
|   @IsOptional() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   @ApiProperty() | ||||
|   /** | ||||
|    * true: only shared albums | ||||
|    * false: only non-shared own albums | ||||
| @@ -18,5 +20,8 @@ export class GetAlbumsDto { | ||||
|    * Ignores the shared parameter | ||||
|    * undefined: get all albums | ||||
|    */ | ||||
|   @IsOptional() | ||||
|   @IsUUID(4) | ||||
|   @ApiProperty({ format: 'uuid' }) | ||||
|   assetId?: string; | ||||
| } | ||||
| @@ -1,2 +1,3 @@ | ||||
| export * from './album.repository'; | ||||
| export * from './album.service'; | ||||
| export * from './response-dto'; | ||||
|   | ||||
| @@ -18,6 +18,7 @@ export const IAssetRepository = 'IAssetRepository'; | ||||
| export interface IAssetRepository { | ||||
|   getByIds(ids: string[]): Promise<AssetEntity[]>; | ||||
|   getWithout(property: WithoutProperty): Promise<AssetEntity[]>; | ||||
|   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>; | ||||
|   deleteAll(ownerId: string): Promise<void>; | ||||
|   getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>; | ||||
|   save(asset: Partial<AssetEntity>): Promise<AssetEntity>; | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common'; | ||||
| import { AlbumService } from './album'; | ||||
| import { APIKeyService } from './api-key'; | ||||
| import { AssetService } from './asset'; | ||||
| import { AuthService } from './auth'; | ||||
| @@ -16,6 +17,7 @@ import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config'; | ||||
| import { UserService } from './user'; | ||||
|  | ||||
| const providers: Provider[] = [ | ||||
|   AlbumService, | ||||
|   AssetService, | ||||
|   APIKeyService, | ||||
|   AuthService, | ||||
|   | ||||
| @@ -3,6 +3,12 @@ import { IAlbumRepository } from '../src'; | ||||
| export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => { | ||||
|   return { | ||||
|     getByIds: jest.fn(), | ||||
|     getByAssetId: jest.fn(), | ||||
|     getAssetCountForIds: jest.fn(), | ||||
|     getInvalidThumbnail: jest.fn(), | ||||
|     getOwned: jest.fn(), | ||||
|     getShared: jest.fn(), | ||||
|     getNotShared: jest.fn(), | ||||
|     deleteAll: jest.fn(), | ||||
|     getAll: jest.fn(), | ||||
|     save: jest.fn(), | ||||
|   | ||||
| @@ -4,6 +4,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => { | ||||
|   return { | ||||
|     getByIds: jest.fn(), | ||||
|     getWithout: jest.fn(), | ||||
|     getFirstAssetForAlbumId: jest.fn(), | ||||
|     getAll: jest.fn(), | ||||
|     deleteAll: jest.fn(), | ||||
|     save: jest.fn(), | ||||
|   | ||||
| @@ -219,6 +219,97 @@ export const albumStub = { | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [], | ||||
|   }), | ||||
|   sharedWithUser: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-2', | ||||
|     albumName: 'Empty album shared with user', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userEntityStub.admin, | ||||
|     assets: [], | ||||
|     albumThumbnailAsset: null, | ||||
|     albumThumbnailAssetId: null, | ||||
|     createdAt: new Date().toISOString(), | ||||
|     updatedAt: new Date().toISOString(), | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [userEntityStub.user1], | ||||
|   }), | ||||
|   sharedWithAdmin: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-3', | ||||
|     albumName: 'Empty album shared with admin', | ||||
|     ownerId: authStub.user1.id, | ||||
|     owner: userEntityStub.user1, | ||||
|     assets: [], | ||||
|     albumThumbnailAsset: null, | ||||
|     albumThumbnailAssetId: null, | ||||
|     createdAt: new Date().toISOString(), | ||||
|     updatedAt: new Date().toISOString(), | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [userEntityStub.admin], | ||||
|   }), | ||||
|   oneAsset: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-4', | ||||
|     albumName: 'Album with one asset', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userEntityStub.admin, | ||||
|     assets: [assetEntityStub.image], | ||||
|     albumThumbnailAsset: null, | ||||
|     albumThumbnailAssetId: null, | ||||
|     createdAt: new Date().toISOString(), | ||||
|     updatedAt: new Date().toISOString(), | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [], | ||||
|   }), | ||||
|   emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-5', | ||||
|     albumName: 'Empty album with invalid thumbnail', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userEntityStub.admin, | ||||
|     assets: [], | ||||
|     albumThumbnailAsset: assetEntityStub.image, | ||||
|     albumThumbnailAssetId: assetEntityStub.image.id, | ||||
|     createdAt: new Date().toISOString(), | ||||
|     updatedAt: new Date().toISOString(), | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [], | ||||
|   }), | ||||
|   emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-5', | ||||
|     albumName: 'Empty album with invalid thumbnail', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userEntityStub.admin, | ||||
|     assets: [], | ||||
|     albumThumbnailAsset: null, | ||||
|     albumThumbnailAssetId: null, | ||||
|     createdAt: new Date().toISOString(), | ||||
|     updatedAt: new Date().toISOString(), | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [], | ||||
|   }), | ||||
|   oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-6', | ||||
|     albumName: 'Album with one asset and invalid thumbnail', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userEntityStub.admin, | ||||
|     assets: [assetEntityStub.image], | ||||
|     albumThumbnailAsset: assetEntityStub.livePhotoMotionAsset, | ||||
|     albumThumbnailAssetId: assetEntityStub.livePhotoMotionAsset.id, | ||||
|     createdAt: new Date().toISOString(), | ||||
|     updatedAt: new Date().toISOString(), | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [], | ||||
|   }), | ||||
|   oneAssetValidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-6', | ||||
|     albumName: 'Album with one asset and invalid thumbnail', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userEntityStub.admin, | ||||
|     assets: [assetEntityStub.image], | ||||
|     albumThumbnailAsset: assetEntityStub.image, | ||||
|     albumThumbnailAssetId: assetEntityStub.image.id, | ||||
|     createdAt: new Date().toISOString(), | ||||
|     updatedAt: new Date().toISOString(), | ||||
|     sharedLinks: [], | ||||
|     sharedUsers: [], | ||||
|   }), | ||||
| }; | ||||
|  | ||||
| const assetInfo: ExifResponseDto = { | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { | ||||
|   Unique, | ||||
|   UpdateDateColumn, | ||||
| } from 'typeorm'; | ||||
| import { AlbumEntity } from './album.entity'; | ||||
| import { ExifEntity } from './exif.entity'; | ||||
| import { SharedLinkEntity } from './shared-link.entity'; | ||||
| import { SmartInfoEntity } from './smart-info.entity'; | ||||
| @@ -99,6 +100,9 @@ export class AssetEntity { | ||||
|   @ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true }) | ||||
|   @JoinTable({ name: 'shared_link__asset' }) | ||||
|   sharedLinks!: SharedLinkEntity[]; | ||||
|  | ||||
|   @ManyToMany(() => AlbumEntity, (album) => album.assets) | ||||
|   albums?: AlbumEntity[]; | ||||
| } | ||||
|  | ||||
| export enum AssetType { | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { IAlbumRepository } from '@app/domain'; | ||||
| import { AlbumAssetCount, IAlbumRepository } from '@app/domain'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { In, Repository } from 'typeorm'; | ||||
| import { In, IsNull, Not, Repository } from 'typeorm'; | ||||
| import { dataSource } from '../config'; | ||||
| import { AlbumEntity } from '../entities'; | ||||
|  | ||||
| @Injectable() | ||||
| @@ -19,6 +20,97 @@ export class AlbumRepository implements IAlbumRepository { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> { | ||||
|     return this.repository.find({ | ||||
|       where: { ownerId, assets: { id: assetId } }, | ||||
|       relations: { owner: true, sharedUsers: true }, | ||||
|       order: { createdAt: 'DESC' }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> { | ||||
|     // Guard against running invalid query when ids list is empty. | ||||
|     if (!ids.length) { | ||||
|       return []; | ||||
|     } | ||||
|  | ||||
|     // Only possible with query builder because of GROUP BY. | ||||
|     const countByAlbums = await this.repository | ||||
|       .createQueryBuilder('album') | ||||
|       .select('album.id') | ||||
|       .addSelect('COUNT(albums_assets.assetsId)', 'asset_count') | ||||
|       .leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id') | ||||
|       .where('album.id IN (:...ids)', { ids }) | ||||
|       .groupBy('album.id') | ||||
|       .getRawMany(); | ||||
|  | ||||
|     return countByAlbums.map<AlbumAssetCount>((albumCount) => ({ | ||||
|       albumId: albumCount['album_id'], | ||||
|       assetCount: Number(albumCount['asset_count']), | ||||
|     })); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Returns the album IDs that have an invalid thumbnail, when: | ||||
|    *  - Thumbnail references an asset outside the album | ||||
|    *  - Empty album still has a thumbnail set | ||||
|    */ | ||||
|   async getInvalidThumbnail(): Promise<string[]> { | ||||
|     // Using dataSource, because there is no direct access to albums_assets_assets. | ||||
|     const albumHasAssets = dataSource | ||||
|       .createQueryBuilder() | ||||
|       .select('1') | ||||
|       .from('albums_assets_assets', 'albums_assets') | ||||
|       .where('"albums"."id" = "albums_assets"."albumsId"'); | ||||
|  | ||||
|     const albumContainsThumbnail = albumHasAssets | ||||
|       .clone() | ||||
|       .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); | ||||
|  | ||||
|     const albums = await this.repository | ||||
|       .createQueryBuilder('albums') | ||||
|       .select('albums.id') | ||||
|       .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) | ||||
|       .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`) | ||||
|       .getMany(); | ||||
|  | ||||
|     return albums.map((album) => album.id); | ||||
|   } | ||||
|  | ||||
|   getOwned(ownerId: string): Promise<AlbumEntity[]> { | ||||
|     return this.repository.find({ | ||||
|       relations: { sharedUsers: true, sharedLinks: true, owner: true }, | ||||
|       where: { ownerId }, | ||||
|       order: { createdAt: 'DESC' }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get albums shared with and shared by owner. | ||||
|    */ | ||||
|   getShared(ownerId: string): Promise<AlbumEntity[]> { | ||||
|     return this.repository.find({ | ||||
|       relations: { sharedUsers: true, sharedLinks: true, owner: true }, | ||||
|       where: [ | ||||
|         { sharedUsers: { id: ownerId } }, | ||||
|         { sharedLinks: { userId: ownerId } }, | ||||
|         { ownerId, sharedUsers: { id: Not(IsNull()) } }, | ||||
|       ], | ||||
|       order: { createdAt: 'DESC' }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get albums of owner that are _not_ shared | ||||
|    */ | ||||
|   getNotShared(ownerId: string): Promise<AlbumEntity[]> { | ||||
|     return this.repository.find({ | ||||
|       relations: { sharedUsers: true, sharedLinks: true, owner: true }, | ||||
|       where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } }, | ||||
|       order: { createdAt: 'DESC' }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async deleteAll(userId: string): Promise<void> { | ||||
|     await this.repository.delete({ ownerId: userId }); | ||||
|   } | ||||
|   | ||||
| @@ -134,4 +134,11 @@ export class AssetRepository implements IAssetRepository { | ||||
|       where, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> { | ||||
|     return this.repository.findOne({ | ||||
|       where: { albums: { id: albumId } }, | ||||
|       order: { fileCreatedAt: 'DESC' }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user