1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

fix(server): album add/remove asset performance (#4516)

This commit is contained in:
Jason Rasmussen 2023-10-18 11:56:00 -04:00 committed by GitHub
parent f4a12acd29
commit 0994575bf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 97 additions and 58 deletions

View file

@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import {
albumStub,
assetStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
@ -225,7 +224,7 @@ describe(AlbumService.name, () => {
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.hasAsset).toHaveBeenCalledWith(albumStub.oneAsset.id, 'not-in-album');
expect(albumMock.hasAsset).toHaveBeenCalledWith({ albumId: 'album-4', assetId: 'not-in-album' });
expect(albumMock.update).not.toHaveBeenCalled();
});
@ -461,6 +460,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -473,9 +473,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
});
it('should not set the thumbnail if the album has one already', async () => {
@ -490,9 +493,9 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-id',
});
expect(albumMock.addAssets).toHaveBeenCalled();
});
it('should allow a shared user to add assets', async () => {
@ -512,9 +515,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
});
it('should allow a shared link user to add assets', async () => {
@ -523,6 +529,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -535,9 +542,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
@ -550,6 +560,7 @@ describe(AlbumService.name, () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' },
@ -558,10 +569,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1',
});
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
});
@ -569,6 +578,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
@ -620,17 +630,14 @@ describe(AlbumService.name, () => {
it('should allow the owner to remove assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [],
albumThumbnailAssetId: null,
});
expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) });
expect(albumMock.removeAssets).toHaveBeenCalledWith({ assetIds: ['asset-id'], albumId: 'album-123' });
});
it('should skip assets not in the album', async () => {
@ -647,9 +654,14 @@ describe(AlbumService.name, () => {
it('should skip assets without user permission to remove', async () => {
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION },
{
success: false,
id: 'asset-id',
error: BulkIdErrorReason.NO_PERMISSION,
},
]);
expect(albumMock.update).not.toHaveBeenCalled();
@ -658,6 +670,7 @@ describe(AlbumService.name, () => {
it('should reset the thumbnail if it is removed', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
@ -666,9 +679,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.withLocation],
albumThumbnailAssetId: assetStub.withLocation.id,
});
expect(albumMock.updateThumbnails).toHaveBeenCalled();
});
});

View file

@ -120,7 +120,7 @@ export class AlbumService {
const album = await this.findOrFail(id, { withAssets: true });
if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
const valid = await this.albumRepository.hasAsset({ albumId: id, assetId: dto.albumThumbnailAssetId });
if (!valid) {
throw new BadRequestException('Invalid album thumbnail');
}
@ -148,35 +148,34 @@ export class AlbumService {
}
async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: true });
const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id);
for (const assetId of dto.ids) {
const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
}
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id);
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId);
if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id, success: true });
album.assets.push({ id } as AssetEntity);
results.push({ id: assetId, success: true });
}
const newAsset = results.find(({ success }) => success);
if (newAsset) {
const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
if (newAssetIds.length > 0) {
await this.albumRepository.addAssets({ albumId: id, assetIds: newAssetIds });
await this.albumRepository.update({
id,
assets: album.assets,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id,
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAssetIds[0],
});
}
@ -184,42 +183,37 @@ export class AlbumService {
}
async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: true });
const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id);
for (const assetId of dto.ids) {
const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (!hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const hasAccess = await this.access.hasAny(authUser, [
{ permission: Permission.ALBUM_REMOVE_ASSET, id },
{ permission: Permission.ASSET_SHARE, id },
{ permission: Permission.ALBUM_REMOVE_ASSET, id: assetId },
{ permission: Permission.ASSET_SHARE, id: assetId },
]);
if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id, success: true });
album.assets = album.assets.filter((asset) => asset.id !== id);
if (album.albumThumbnailAssetId === id) {
album.albumThumbnailAssetId = null;
}
results.push({ id: assetId, success: true });
}
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
await this.albumRepository.update({
id,
assets: album.assets,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null,
});
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0) {
await this.albumRepository.removeAssets({ albumId: id, assetIds: removedIds });
await this.albumRepository.update({ id, updatedAt: new Date() });
if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
}
}
return results;

View file

@ -11,13 +11,24 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
export interface AlbumAsset {
albumId: string;
assetId: string;
}
export interface AlbumAssets {
albumId: string;
assetIds: string[];
}
export interface IAlbumRepository {
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>;
getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
hasAsset(id: string, assetId: string): Promise<boolean>;
/** Remove an asset from _all_ albums */
removeAsset(id: string): Promise<void>;
addAssets(assets: AlbumAssets): Promise<void>;
hasAsset(asset: AlbumAsset): Promise<boolean>;
removeAsset(assetId: string): Promise<void>;
removeAssets(assets: AlbumAssets): Promise<void>;
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getInvalidThumbnail(): Promise<string[]>;
getOwned(ownerId: string): Promise<AlbumEntity[]>;

View file

@ -1,4 +1,4 @@
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from '@app/domain';
import { AlbumAsset, AlbumAssetCount, AlbumAssets, AlbumInfoOptions, IAlbumRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
@ -168,16 +168,27 @@ export class AlbumRepository implements IAlbumRepository {
.createQueryBuilder()
.delete()
.from('albums_assets_assets')
.where('"albums_assets_assets"."assetsId" = :assetId', { assetId })
.where('"albums_assets_assets"."assetsId" = :assetId', { assetId });
}
async removeAssets(asset: AlbumAssets): Promise<void> {
await this.dataSource
.createQueryBuilder()
.delete()
.from('albums_assets_assets')
.where({
albumsId: asset.albumId,
assetsId: In(asset.assetIds),
})
.execute();
}
hasAsset(id: string, assetId: string): Promise<boolean> {
hasAsset(asset: AlbumAsset): Promise<boolean> {
return this.repository.exist({
where: {
id,
id: asset.albumId,
assets: {
id: assetId,
id: asset.assetId,
},
},
relations: {
@ -186,6 +197,15 @@ export class AlbumRepository implements IAlbumRepository {
});
}
async addAssets({ albumId, assetIds }: AlbumAssets): Promise<void> {
await this.dataSource
.createQueryBuilder()
.insert()
.into('albums_assets_assets', ['albumsId', 'assetsId'])
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
.execute();
}
async create(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album);
}

View file

@ -14,7 +14,9 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
softDeleteAll: jest.fn(),
deleteAll: jest.fn(),
getAll: jest.fn(),
addAssets: jest.fn(),
removeAsset: jest.fn(),
removeAssets: jest.fn(),
hasAsset: jest.fn(),
create: jest.fn(),
update: jest.fn(),