浏览代码

feat(server): improve and refactor get all albums (#2048)

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Michel Heusschen 2 年之前
父节点
当前提交
c74fba483d

+ 1 - 1
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);

+ 1 - 74
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<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 },

+ 0 - 10
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(

+ 1 - 90
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, {});
-  });
 });

+ 1 - 28
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<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);

+ 2 - 0
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,

+ 21 - 0
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);
+  }
+}

+ 1 - 0
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';

+ 87 - 31
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);
+      });
     });
   });
 });

+ 631 - 630
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": {

+ 11 - 0
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<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 - 0
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<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 - 0
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<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;
+  }
+}

+ 7 - 2
server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts → 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;
 }

+ 1 - 0
server/libs/domain/src/album/index.ts

@@ -1,2 +1,3 @@
 export * from './album.repository';
+export * from './album.service';
 export * from './response-dto';

+ 1 - 0
server/libs/domain/src/asset/asset.repository.ts

@@ -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>;

+ 2 - 0
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,

+ 6 - 0
server/libs/domain/test/album.repository.mock.ts

@@ -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(),

+ 1 - 0
server/libs/domain/test/asset.repository.mock.ts

@@ -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(),

+ 91 - 0
server/libs/domain/test/fixtures.ts

@@ -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 = {

+ 4 - 0
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 {

+ 94 - 2
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<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 });
   }

+ 7 - 0
server/libs/infra/src/db/repository/asset.repository.ts

@@ -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' },
+    });
+  }
 }