diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index e7d544708e..c51909bace 100644 --- a/mobile/openapi/doc/AlbumApi.md +++ b/mobile/openapi/doc/AlbumApi.md @@ -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); diff --git a/server/apps/immich/src/api-v1/album/album-repository.ts b/server/apps/immich/src/api-v1/album/album-repository.ts index bdeaa4b3ce..bcd779940a 100644 --- a/server/apps/immich/src/api-v1/album/album-repository.ts +++ b/server/apps/immich/src/api-v1/album/album-repository.ts @@ -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; - getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise; - getPublicSharingList(ownerId: string): Promise; get(albumId: string): Promise; delete(album: AlbumEntity): Promise; addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise; @@ -23,7 +20,6 @@ export interface IAlbumRepository { addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise; updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise; updateThumbnails(): Promise; - getListByAssetId(userId: string, assetId: string): Promise; getCountByUserId(userId: string): Promise; getSharedWithUserAlbumCount(userId: string, assetId: string): Promise; } @@ -40,22 +36,6 @@ export class AlbumRepository implements IAlbumRepository { private assetRepository: Repository, ) {} - async getPublicSharingList(ownerId: string): Promise { - return this.albumRepository.find({ - relations: { - sharedLinks: true, - assets: true, - owner: true, - }, - where: { - ownerId, - sharedLinks: { - id: Not(IsNull()), - }, - }, - }); - } - async getCountByUserId(userId: string): Promise { 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; } - async getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise { - const filteringByShared = typeof getAlbumsDto.shared == 'boolean'; - const userId = ownerId; - - const queryProperties: FindManyOptions = { - relations: { sharedUsers: true, assets: true, sharedLinks: true, owner: true }, - select: { assets: { id: true } }, - order: { createdAt: 'DESC' }, - }; - - let albumsQuery: Promise; - - /** - * `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 { - 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 { return this.albumRepository.findOne({ where: { id: albumId }, diff --git a/server/apps/immich/src/api-v1/album/album.controller.ts b/server/apps/immich/src/api-v1/album/album.controller.ts index d41c6ec421..cf94e9e6ac 100644 --- a/server/apps/immich/src/api-v1/album/album.controller.ts +++ b/server/apps/immich/src/api-v1/album/album.controller.ts @@ -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( diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index 699c5e8db8..a7536ace80 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -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, {}); - }); }); diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index f73aaf3061..1c64df874e 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -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 { - 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 { const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); return mapAlbum(album); diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index a59315b5b2..1d82ed3d91 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -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, diff --git a/server/apps/immich/src/controllers/album.controller.ts b/server/apps/immich/src/controllers/album.controller.ts new file mode 100644 index 0000000000..01a1ebee7d --- /dev/null +++ b/server/apps/immich/src/controllers/album.controller.ts @@ -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); + } +} diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index f8eee61f83..4e2003fb3c 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -1,3 +1,4 @@ +export * from './album.controller'; export * from './api-key.controller'; export * from './auth.controller'; export * from './device-info.controller'; diff --git a/server/apps/immich/test/album.e2e-spec.ts b/server/apps/immich/test/album.e2e-spec.ts index 82ae39926c..7346fc6efd 100644 --- a/server/apps/immich/test/album.e2e-spec.ts +++ b/server/apps/immich/test/album.e2e-spec.ts @@ -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', - // }; - - // const { status, body } = await _createAlbum(app, data); + describe('with empty DB', () => { + it('rejects invalid shared param', async () => { + const { status } = await request(app.getHttpServer()).get('/album?shared=invalid'); + expect(status).toEqual(400); + }); - // expect(status).toEqual(201); + it('rejects invalid assetId param', async () => { + const { status } = await request(app.getHttpServer()).get('/album?assetId=invalid'); + expect(status).toEqual(400); + }); - // 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); + }); }); }); }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 27aa6738db..65ca13d05b 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1,6 +1,96 @@ { "openapi": "3.0.0", "paths": { + "/album": { + "get": { + "operationId": "getAllAlbums", + "description": "", + "parameters": [ + { + "name": "shared", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "assetId", + "required": false, + "in": "query", + "description": "Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlbumResponseDto" + } + } + } + } + } + }, + "tags": [ + "Album" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + } + ] + }, + "post": { + "operationId": "createAlbum", + "description": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlbumDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlbumResponseDto" + } + } + } + } + }, + "tags": [ + "Album" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, "/api-key": { "post": { "operationId": "createKey", @@ -2865,95 +2955,6 @@ ] } }, - "/album": { - "post": { - "operationId": "createAlbum", - "description": "", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAlbumDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlbumResponseDto" - } - } - } - } - }, - "tags": [ - "Album" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - } - ] - }, - "get": { - "operationId": "getAllAlbums", - "description": "", - "parameters": [ - { - "name": "shared", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "assetId", - "required": false, - "in": "query", - "description": "Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AlbumResponseDto" - } - } - } - } - } - }, - "tags": [ - "Album" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - } - ] - } - }, "/album/{albumId}/users": { "put": { "operationId": "addUsersToAlbum", @@ -3395,789 +3396,789 @@ } }, "schemas": { - "APIKeyCreateDto": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - }, - "APIKeyResponseDto": { + "UserResponseDto": { "type": "object", "properties": { "id": { "type": "string" }, - "name": { + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { "type": "string" }, "createdAt": { "type": "string" }, + "profileImagePath": { + "type": "string" + }, + "shouldChangePassword": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "deletedAt": { + "format": "date-time", + "type": "string" + }, "updatedAt": { "type": "string" + }, + "oauthId": { + "type": "string" } }, "required": [ "id", - "name", + "email", + "firstName", + "lastName", "createdAt", - "updatedAt" - ] - }, - "APIKeyCreateResponseDto": { - "type": "object", - "properties": { - "secret": { - "type": "string" - }, - "apiKey": { - "$ref": "#/components/schemas/APIKeyResponseDto" - } - }, - "required": [ - "secret", - "apiKey" + "profileImagePath", + "shouldChangePassword", + "isAdmin", + "oauthId" ] }, - "APIKeyUpdateDto": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" + "AssetTypeEnum": { + "type": "string", + "enum": [ + "IMAGE", + "VIDEO", + "AUDIO", + "OTHER" ] }, - "LoginCredentialDto": { + "ExifResponseDto": { "type": "object", "properties": { - "email": { - "type": "string", - "example": "testuser@email.com" + "fileSizeInByte": { + "type": "integer", + "nullable": true, + "default": null, + "format": "int64" }, - "password": { + "make": { "type": "string", - "example": "password" - } - }, - "required": [ - "email", - "password" - ] - }, - "LoginResponseDto": { - "type": "object", - "properties": { - "accessToken": { + "nullable": true, + "default": null + }, + "model": { "type": "string", - "readOnly": true + "nullable": true, + "default": null }, - "userId": { + "imageName": { "type": "string", - "readOnly": true + "nullable": true, + "default": null }, - "userEmail": { + "exifImageWidth": { + "type": "number", + "nullable": true, + "default": null + }, + "exifImageHeight": { + "type": "number", + "nullable": true, + "default": null + }, + "orientation": { "type": "string", - "readOnly": true + "nullable": true, + "default": null }, - "firstName": { + "dateTimeOriginal": { + "format": "date-time", "type": "string", - "readOnly": true + "nullable": true, + "default": null }, - "lastName": { + "modifyDate": { + "format": "date-time", "type": "string", - "readOnly": true + "nullable": true, + "default": null }, - "profileImagePath": { + "lensModel": { "type": "string", - "readOnly": true + "nullable": true, + "default": null }, - "isAdmin": { - "type": "boolean", - "readOnly": true + "fNumber": { + "type": "number", + "nullable": true, + "default": null }, - "shouldChangePassword": { - "type": "boolean", - "readOnly": true - } - }, - "required": [ - "accessToken", - "userId", - "userEmail", - "firstName", - "lastName", - "profileImagePath", - "isAdmin", - "shouldChangePassword" - ] - }, - "SignUpDto": { - "type": "object", - "properties": { - "email": { + "focalLength": { + "type": "number", + "nullable": true, + "default": null + }, + "iso": { + "type": "number", + "nullable": true, + "default": null + }, + "exposureTime": { "type": "string", - "example": "testuser@email.com" + "nullable": true, + "default": null }, - "password": { + "latitude": { + "type": "number", + "nullable": true, + "default": null + }, + "longitude": { + "type": "number", + "nullable": true, + "default": null + }, + "city": { "type": "string", - "example": "password" + "nullable": true, + "default": null }, - "firstName": { + "state": { "type": "string", - "example": "Admin" + "nullable": true, + "default": null }, - "lastName": { + "country": { "type": "string", - "example": "Doe" + "nullable": true, + "default": null } - }, - "required": [ - "email", - "password", - "firstName", - "lastName" + } + }, + "SmartInfoResponseDto": { + "type": "object", + "properties": { + "tags": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } + }, + "objects": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TagTypeEnum": { + "type": "string", + "enum": [ + "OBJECT", + "FACE", + "CUSTOM" ] }, - "AdminSignupResponseDto": { + "TagResponseDto": { "type": "object", "properties": { "id": { "type": "string" }, - "email": { - "type": "string" + "type": { + "$ref": "#/components/schemas/TagTypeEnum" }, - "firstName": { + "name": { "type": "string" }, - "lastName": { + "userId": { "type": "string" }, - "createdAt": { - "type": "string" + "renameTagId": { + "type": "string", + "nullable": true } }, "required": [ "id", - "email", - "firstName", - "lastName", - "createdAt" - ] - }, - "ValidateAccessTokenResponseDto": { - "type": "object", - "properties": { - "authStatus": { - "type": "boolean" - } - }, - "required": [ - "authStatus" + "type", + "name", + "userId" ] }, - "ChangePasswordDto": { + "AssetResponseDto": { "type": "object", "properties": { - "password": { - "type": "string", - "example": "password" + "type": { + "$ref": "#/components/schemas/AssetTypeEnum" }, - "newPassword": { - "type": "string", - "example": "password" - } - }, - "required": [ - "password", - "newPassword" - ] - }, - "UserResponseDto": { - "type": "object", - "properties": { "id": { "type": "string" }, - "email": { - "type": "string" - }, - "firstName": { + "deviceAssetId": { "type": "string" }, - "lastName": { + "ownerId": { "type": "string" }, - "createdAt": { + "deviceId": { "type": "string" }, - "profileImagePath": { + "originalPath": { "type": "string" }, - "shouldChangePassword": { - "type": "boolean" + "resizePath": { + "type": "string", + "nullable": true }, - "isAdmin": { - "type": "boolean" + "fileCreatedAt": { + "type": "string" }, - "deletedAt": { - "format": "date-time", + "fileModifiedAt": { "type": "string" }, "updatedAt": { "type": "string" }, - "oauthId": { + "isFavorite": { + "type": "boolean" + }, + "mimeType": { + "type": "string", + "nullable": true + }, + "duration": { "type": "string" - } - }, - "required": [ - "id", - "email", - "firstName", - "lastName", - "createdAt", - "profileImagePath", - "shouldChangePassword", - "isAdmin", - "oauthId" - ] - }, - "LogoutResponseDto": { - "type": "object", - "properties": { - "successful": { - "type": "boolean", - "readOnly": true }, - "redirectUri": { + "webpPath": { "type": "string", - "readOnly": true - } - }, - "required": [ - "successful", - "redirectUri" - ] - }, - "DeviceTypeEnum": { - "type": "string", - "enum": [ - "IOS", - "ANDROID", - "WEB" - ] - }, - "UpsertDeviceInfoDto": { - "type": "object", - "properties": { - "deviceType": { - "$ref": "#/components/schemas/DeviceTypeEnum" + "nullable": true }, - "deviceId": { - "type": "string" + "encodedVideoPath": { + "type": "string", + "nullable": true }, - "isAutoBackup": { - "type": "boolean" + "exifInfo": { + "$ref": "#/components/schemas/ExifResponseDto" + }, + "smartInfo": { + "$ref": "#/components/schemas/SmartInfoResponseDto" + }, + "livePhotoVideoId": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagResponseDto" + } } }, "required": [ - "deviceType", - "deviceId" + "type", + "id", + "deviceAssetId", + "ownerId", + "deviceId", + "originalPath", + "resizePath", + "fileCreatedAt", + "fileModifiedAt", + "updatedAt", + "isFavorite", + "mimeType", + "duration", + "webpPath" ] }, - "DeviceInfoResponseDto": { + "AlbumResponseDto": { "type": "object", "properties": { - "id": { + "assetCount": { "type": "integer" }, - "deviceType": { - "$ref": "#/components/schemas/DeviceTypeEnum" + "id": { + "type": "string" }, - "userId": { + "ownerId": { "type": "string" }, - "deviceId": { + "albumName": { "type": "string" }, "createdAt": { "type": "string" }, - "isAutoBackup": { + "updatedAt": { + "type": "string" + }, + "albumThumbnailAssetId": { + "type": "string", + "nullable": true + }, + "shared": { "type": "boolean" + }, + "sharedUsers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserResponseDto" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + }, + "owner": { + "$ref": "#/components/schemas/UserResponseDto" } }, "required": [ + "assetCount", "id", - "deviceType", - "userId", - "deviceId", + "ownerId", + "albumName", "createdAt", - "isAutoBackup" + "updatedAt", + "albumThumbnailAssetId", + "shared", + "sharedUsers", + "assets", + "owner" ] }, - "JobCountsDto": { + "APIKeyCreateDto": { "type": "object", "properties": { - "active": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "delayed": { - "type": "integer" - }, - "waiting": { - "type": "integer" + "name": { + "type": "string" } - }, - "required": [ - "active", - "completed", - "failed", - "delayed", - "waiting" - ] + } }, - "AllJobStatusResponseDto": { + "APIKeyResponseDto": { "type": "object", "properties": { - "thumbnail-generation-queue": { - "$ref": "#/components/schemas/JobCountsDto" - }, - "metadata-extraction-queue": { - "$ref": "#/components/schemas/JobCountsDto" - }, - "video-conversion-queue": { - "$ref": "#/components/schemas/JobCountsDto" - }, - "object-tagging-queue": { - "$ref": "#/components/schemas/JobCountsDto" - }, - "clip-encoding-queue": { - "$ref": "#/components/schemas/JobCountsDto" + "id": { + "type": "string" }, - "storage-template-migration-queue": { - "$ref": "#/components/schemas/JobCountsDto" + "name": { + "type": "string" }, - "background-task-queue": { - "$ref": "#/components/schemas/JobCountsDto" + "createdAt": { + "type": "string" }, - "search-queue": { - "$ref": "#/components/schemas/JobCountsDto" + "updatedAt": { + "type": "string" } }, "required": [ - "thumbnail-generation-queue", - "metadata-extraction-queue", - "video-conversion-queue", - "object-tagging-queue", - "clip-encoding-queue", - "storage-template-migration-queue", - "background-task-queue", - "search-queue" - ] - }, - "JobName": { - "type": "string", - "enum": [ - "thumbnail-generation-queue", - "metadata-extraction-queue", - "video-conversion-queue", - "object-tagging-queue", - "clip-encoding-queue", - "background-task-queue", - "storage-template-migration-queue", - "search-queue" - ] - }, - "JobCommand": { - "type": "string", - "enum": [ - "start", - "pause", - "empty" + "id", + "name", + "createdAt", + "updatedAt" ] }, - "JobCommandDto": { + "APIKeyCreateResponseDto": { "type": "object", "properties": { - "command": { - "$ref": "#/components/schemas/JobCommand" + "secret": { + "type": "string" }, - "force": { - "type": "boolean" + "apiKey": { + "$ref": "#/components/schemas/APIKeyResponseDto" } }, "required": [ - "command", - "force" + "secret", + "apiKey" ] }, - "OAuthConfigDto": { + "APIKeyUpdateDto": { "type": "object", "properties": { - "redirectUri": { + "name": { "type": "string" } }, "required": [ - "redirectUri" + "name" ] }, - "OAuthConfigResponseDto": { + "LoginCredentialDto": { "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "passwordLoginEnabled": { - "type": "boolean" - }, - "url": { - "type": "string" - }, - "buttonText": { - "type": "string" + "email": { + "type": "string", + "example": "testuser@email.com" }, - "autoLaunch": { - "type": "boolean" + "password": { + "type": "string", + "example": "password" } }, "required": [ - "enabled", - "passwordLoginEnabled" - ] - }, - "OAuthCallbackDto": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": [ - "url" - ] - }, - "AssetTypeEnum": { - "type": "string", - "enum": [ - "IMAGE", - "VIDEO", - "AUDIO", - "OTHER" + "email", + "password" ] }, - "ExifResponseDto": { + "LoginResponseDto": { "type": "object", "properties": { - "fileSizeInByte": { - "type": "integer", - "nullable": true, - "default": null, - "format": "int64" - }, - "make": { + "accessToken": { "type": "string", - "nullable": true, - "default": null + "readOnly": true }, - "model": { + "userId": { "type": "string", - "nullable": true, - "default": null + "readOnly": true }, - "imageName": { + "userEmail": { "type": "string", - "nullable": true, - "default": null - }, - "exifImageWidth": { - "type": "number", - "nullable": true, - "default": null - }, - "exifImageHeight": { - "type": "number", - "nullable": true, - "default": null + "readOnly": true }, - "orientation": { + "firstName": { "type": "string", - "nullable": true, - "default": null + "readOnly": true }, - "dateTimeOriginal": { - "format": "date-time", + "lastName": { "type": "string", - "nullable": true, - "default": null + "readOnly": true }, - "modifyDate": { - "format": "date-time", + "profileImagePath": { "type": "string", - "nullable": true, - "default": null + "readOnly": true }, - "lensModel": { - "type": "string", - "nullable": true, - "default": null + "isAdmin": { + "type": "boolean", + "readOnly": true }, - "fNumber": { - "type": "number", - "nullable": true, - "default": null + "shouldChangePassword": { + "type": "boolean", + "readOnly": true + } + }, + "required": [ + "accessToken", + "userId", + "userEmail", + "firstName", + "lastName", + "profileImagePath", + "isAdmin", + "shouldChangePassword" + ] + }, + "SignUpDto": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "testuser@email.com" }, - "focalLength": { - "type": "number", - "nullable": true, - "default": null + "password": { + "type": "string", + "example": "password" }, - "iso": { - "type": "number", - "nullable": true, - "default": null + "firstName": { + "type": "string", + "example": "Admin" }, - "exposureTime": { + "lastName": { "type": "string", - "nullable": true, - "default": null + "example": "Doe" + } + }, + "required": [ + "email", + "password", + "firstName", + "lastName" + ] + }, + "AdminSignupResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "latitude": { - "type": "number", - "nullable": true, - "default": null + "email": { + "type": "string" }, - "longitude": { - "type": "number", - "nullable": true, - "default": null + "firstName": { + "type": "string" }, - "city": { - "type": "string", - "nullable": true, - "default": null + "lastName": { + "type": "string" }, - "state": { + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "email", + "firstName", + "lastName", + "createdAt" + ] + }, + "ValidateAccessTokenResponseDto": { + "type": "object", + "properties": { + "authStatus": { + "type": "boolean" + } + }, + "required": [ + "authStatus" + ] + }, + "ChangePasswordDto": { + "type": "object", + "properties": { + "password": { "type": "string", - "nullable": true, - "default": null + "example": "password" }, - "country": { + "newPassword": { "type": "string", - "nullable": true, - "default": null + "example": "password" } - } + }, + "required": [ + "password", + "newPassword" + ] }, - "SmartInfoResponseDto": { + "LogoutResponseDto": { "type": "object", "properties": { - "tags": { - "nullable": true, - "type": "array", - "items": { - "type": "string" - } + "successful": { + "type": "boolean", + "readOnly": true }, - "objects": { - "nullable": true, - "type": "array", - "items": { - "type": "string" - } + "redirectUri": { + "type": "string", + "readOnly": true } - } + }, + "required": [ + "successful", + "redirectUri" + ] }, - "TagTypeEnum": { + "DeviceTypeEnum": { "type": "string", "enum": [ - "OBJECT", - "FACE", - "CUSTOM" + "IOS", + "ANDROID", + "WEB" ] }, - "TagResponseDto": { + "UpsertDeviceInfoDto": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" - }, - "name": { - "type": "string" + "deviceType": { + "$ref": "#/components/schemas/DeviceTypeEnum" }, - "userId": { + "deviceId": { "type": "string" }, - "renameTagId": { - "type": "string", - "nullable": true + "isAutoBackup": { + "type": "boolean" } }, "required": [ - "id", - "type", - "name", - "userId" + "deviceType", + "deviceId" ] }, - "AssetResponseDto": { + "DeviceInfoResponseDto": { "type": "object", "properties": { - "type": { - "$ref": "#/components/schemas/AssetTypeEnum" - }, "id": { - "type": "string" + "type": "integer" }, - "deviceAssetId": { - "type": "string" + "deviceType": { + "$ref": "#/components/schemas/DeviceTypeEnum" }, - "ownerId": { + "userId": { "type": "string" }, "deviceId": { "type": "string" }, - "originalPath": { + "createdAt": { "type": "string" }, - "resizePath": { - "type": "string", - "nullable": true - }, - "fileCreatedAt": { - "type": "string" + "isAutoBackup": { + "type": "boolean" + } + }, + "required": [ + "id", + "deviceType", + "userId", + "deviceId", + "createdAt", + "isAutoBackup" + ] + }, + "JobCountsDto": { + "type": "object", + "properties": { + "active": { + "type": "integer" }, - "fileModifiedAt": { - "type": "string" + "completed": { + "type": "integer" }, - "updatedAt": { - "type": "string" + "failed": { + "type": "integer" }, - "isFavorite": { - "type": "boolean" + "delayed": { + "type": "integer" }, - "mimeType": { - "type": "string", - "nullable": true + "waiting": { + "type": "integer" + } + }, + "required": [ + "active", + "completed", + "failed", + "delayed", + "waiting" + ] + }, + "AllJobStatusResponseDto": { + "type": "object", + "properties": { + "thumbnail-generation-queue": { + "$ref": "#/components/schemas/JobCountsDto" }, - "duration": { - "type": "string" + "metadata-extraction-queue": { + "$ref": "#/components/schemas/JobCountsDto" }, - "webpPath": { - "type": "string", - "nullable": true + "video-conversion-queue": { + "$ref": "#/components/schemas/JobCountsDto" }, - "encodedVideoPath": { - "type": "string", - "nullable": true + "object-tagging-queue": { + "$ref": "#/components/schemas/JobCountsDto" }, - "exifInfo": { - "$ref": "#/components/schemas/ExifResponseDto" + "clip-encoding-queue": { + "$ref": "#/components/schemas/JobCountsDto" }, - "smartInfo": { - "$ref": "#/components/schemas/SmartInfoResponseDto" + "storage-template-migration-queue": { + "$ref": "#/components/schemas/JobCountsDto" }, - "livePhotoVideoId": { - "type": "string", - "nullable": true + "background-task-queue": { + "$ref": "#/components/schemas/JobCountsDto" }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagResponseDto" - } + "search-queue": { + "$ref": "#/components/schemas/JobCountsDto" } }, "required": [ - "type", - "id", - "deviceAssetId", - "ownerId", - "deviceId", - "originalPath", - "resizePath", - "fileCreatedAt", - "fileModifiedAt", - "updatedAt", - "isFavorite", - "mimeType", - "duration", - "webpPath" + "thumbnail-generation-queue", + "metadata-extraction-queue", + "video-conversion-queue", + "object-tagging-queue", + "clip-encoding-queue", + "storage-template-migration-queue", + "background-task-queue", + "search-queue" ] }, - "AlbumResponseDto": { + "JobName": { + "type": "string", + "enum": [ + "thumbnail-generation-queue", + "metadata-extraction-queue", + "video-conversion-queue", + "object-tagging-queue", + "clip-encoding-queue", + "background-task-queue", + "storage-template-migration-queue", + "search-queue" + ] + }, + "JobCommand": { + "type": "string", + "enum": [ + "start", + "pause", + "empty" + ] + }, + "JobCommandDto": { "type": "object", "properties": { - "assetCount": { - "type": "integer" - }, - "id": { - "type": "string" + "command": { + "$ref": "#/components/schemas/JobCommand" }, - "ownerId": { + "force": { + "type": "boolean" + } + }, + "required": [ + "command", + "force" + ] + }, + "OAuthConfigDto": { + "type": "object", + "properties": { + "redirectUri": { "type": "string" + } + }, + "required": [ + "redirectUri" + ] + }, + "OAuthConfigResponseDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "albumName": { - "type": "string" + "passwordLoginEnabled": { + "type": "boolean" }, - "createdAt": { + "url": { "type": "string" }, - "updatedAt": { + "buttonText": { "type": "string" }, - "albumThumbnailAssetId": { - "type": "string", - "nullable": true - }, - "shared": { + "autoLaunch": { "type": "boolean" - }, - "sharedUsers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserResponseDto" - } - }, - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - } - }, - "owner": { - "$ref": "#/components/schemas/UserResponseDto" } }, "required": [ - "assetCount", - "id", - "ownerId", - "albumName", - "createdAt", - "updatedAt", - "albumThumbnailAssetId", - "shared", - "sharedUsers", - "assets", - "owner" + "enabled", + "passwordLoginEnabled" + ] + }, + "OAuthCallbackDto": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" ] }, "SearchFacetCountResponseDto": { diff --git a/server/libs/domain/src/album/album.repository.ts b/server/libs/domain/src/album/album.repository.ts index 424b901776..e19be5bb9f 100644 --- a/server/libs/domain/src/album/album.repository.ts +++ b/server/libs/domain/src/album/album.repository.ts @@ -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; + getByAssetId(ownerId: string, assetId: string): Promise; + getAssetCountForIds(ids: string[]): Promise; + getInvalidThumbnail(): Promise; + getOwned(ownerId: string): Promise; + getShared(ownerId: string): Promise; + getNotShared(ownerId: string): Promise; deleteAll(userId: string): Promise; getAll(): Promise; save(album: Partial): Promise; diff --git a/server/libs/domain/src/album/album.service.spec.ts b/server/libs/domain/src/album/album.service.spec.ts new file mode 100644 index 0000000000..7555065004 --- /dev/null +++ b/server/libs/domain/src/album/album.service.spec.ts @@ -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; + let assetMock: jest.Mocked; + + 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); + }); +}); diff --git a/server/libs/domain/src/album/album.service.ts b/server/libs/domain/src/album/album.service.ts new file mode 100644 index 0000000000..5f963e9de3 --- /dev/null +++ b/server/libs/domain/src/album/album.service.ts @@ -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 { + 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, { 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 { + 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; + } +} diff --git a/server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts b/server/libs/domain/src/album/dto/get-albums.dto.ts similarity index 61% rename from server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts rename to server/libs/domain/src/album/dto/get-albums.dto.ts index fe03831f70..afcde1f129 100644 --- a/server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts +++ b/server/libs/domain/src/album/dto/get-albums.dto.ts @@ -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; } diff --git a/server/libs/domain/src/album/index.ts b/server/libs/domain/src/album/index.ts index 1231926aa2..5dd1b2e677 100644 --- a/server/libs/domain/src/album/index.ts +++ b/server/libs/domain/src/album/index.ts @@ -1,2 +1,3 @@ export * from './album.repository'; +export * from './album.service'; export * from './response-dto'; diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts index 3d0b60c361..d4c6f0e8cf 100644 --- a/server/libs/domain/src/asset/asset.repository.ts +++ b/server/libs/domain/src/asset/asset.repository.ts @@ -18,6 +18,7 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { getByIds(ids: string[]): Promise; getWithout(property: WithoutProperty): Promise; + getFirstAssetForAlbumId(albumId: string): Promise; deleteAll(ownerId: string): Promise; getAll(options?: AssetSearchOptions): Promise; save(asset: Partial): Promise; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 7a6793d480..712b5eab84 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -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, diff --git a/server/libs/domain/test/album.repository.mock.ts b/server/libs/domain/test/album.repository.mock.ts index 2c4a5500a2..ad9d9ce984 100644 --- a/server/libs/domain/test/album.repository.mock.ts +++ b/server/libs/domain/test/album.repository.mock.ts @@ -3,6 +3,12 @@ import { IAlbumRepository } from '../src'; export const newAlbumRepositoryMock = (): jest.Mocked => { 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(), diff --git a/server/libs/domain/test/asset.repository.mock.ts b/server/libs/domain/test/asset.repository.mock.ts index 314804c2c5..cde4daaf58 100644 --- a/server/libs/domain/test/asset.repository.mock.ts +++ b/server/libs/domain/test/asset.repository.mock.ts @@ -4,6 +4,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { return { getByIds: jest.fn(), getWithout: jest.fn(), + getFirstAssetForAlbumId: jest.fn(), getAll: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 9e3e3a286d..aab3cb52fe 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -219,6 +219,97 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], }), + sharedWithUser: Object.freeze({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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 = { diff --git a/server/libs/infra/src/db/entities/asset.entity.ts b/server/libs/infra/src/db/entities/asset.entity.ts index 6c7b76f15f..6c003484e5 100644 --- a/server/libs/infra/src/db/entities/asset.entity.ts +++ b/server/libs/infra/src/db/entities/asset.entity.ts @@ -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 { diff --git a/server/libs/infra/src/db/repository/album.repository.ts b/server/libs/infra/src/db/repository/album.repository.ts index 9542227fd4..2187ceabdd 100644 --- a/server/libs/infra/src/db/repository/album.repository.ts +++ b/server/libs/infra/src/db/repository/album.repository.ts @@ -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 { + return this.repository.find({ + where: { ownerId, assets: { id: assetId } }, + relations: { owner: true, sharedUsers: true }, + order: { createdAt: 'DESC' }, + }); + } + + async getAssetCountForIds(ids: string[]): Promise { + // 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((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 { + // 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 { + 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 { + 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 { + 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 { await this.repository.delete({ ownerId: userId }); } diff --git a/server/libs/infra/src/db/repository/asset.repository.ts b/server/libs/infra/src/db/repository/asset.repository.ts index 38e57b4f1a..fd11205418 100644 --- a/server/libs/infra/src/db/repository/asset.repository.ts +++ b/server/libs/infra/src/db/repository/asset.repository.ts @@ -134,4 +134,11 @@ export class AssetRepository implements IAssetRepository { where, }); } + + getFirstAssetForAlbumId(albumId: string): Promise { + return this.repository.findOne({ + where: { albums: { id: albumId } }, + order: { fileCreatedAt: 'DESC' }, + }); + } }