Files
immich/server/src/services/album.service.ts
T
Thomas da7a81b752 chore(server): split album update notifications into multiple jobs (#17879)
We would like to move away from the concept of finding and removing pending
jobs. The only place this is used is for album update notifications, and this
is done so that users who initially uploaded assets to an album will also
receive a notification if someone else then adds assets to the same album. This
can also be achieved with a job for each recipient. Multiple jobs also has the
advantage that it will scale better for albums with many users, it's possible
to send notifications concurrently, retries are possible without sending
duplicate notifications, and it's clear what recipient a job failed for.
2025-04-30 17:45:35 -04:00

263 lines
9.3 KiB
TypeScript

import { BadRequestException, Injectable } from '@nestjs/common';
import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumStatisticsResponseDto,
CreateAlbumDto,
GetAlbumsDto,
mapAlbum,
MapAlbumDto,
mapAlbumWithAssets,
mapAlbumWithoutAssets,
UpdateAlbumDto,
UpdateAlbumUserDto,
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable()
export class AlbumService extends BaseService {
async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
const [owned, shared, notShared] = await Promise.all([
this.albumRepository.getOwned(auth.user.id),
this.albumRepository.getShared(auth.user.id),
this.albumRepository.getNotShared(auth.user.id),
]);
return {
owned: owned.length,
shared: shared.length,
notShared: notShared.length,
};
}
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[];
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 results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadata: Record<string, AlbumAssetCount> = {};
for (const metadata of results) {
albumMetadata[metadata.albumId] = metadata;
}
return albums.map((album) => ({
...mapAlbumWithoutAssets(album),
sharedLinks: undefined,
startDate: albumMetadata[album.id]?.startDate ?? undefined,
endDate: albumMetadata[album.id]?.endDate ?? undefined,
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
}));
}
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [id] });
await this.albumRepository.updateThumbnails();
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
return {
...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds?.endDate ?? undefined,
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined,
};
}
async create(auth: AuthDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
const albumUsers = dto.albumUsers || [];
for (const { userId } of albumUsers) {
const exists = await this.userRepository.get(userId, {});
if (!exists) {
throw new BadRequestException('User not found');
}
}
const allowedAssetIdsSet = await this.checkAccess({
auth,
permission: Permission.ASSET_SHARE,
ids: dto.assetIds || [],
});
const assetIds = [...allowedAssetIdsSet].map((id) => id);
const album = await this.albumRepository.create(
{
ownerId: auth.user.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: assetIds[0] || null,
},
assetIds,
albumUsers,
);
for (const { userId } of albumUsers) {
await this.eventRepository.emit('album.invite', { id: album.id, userId });
}
return mapAlbumWithAssets(album);
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.ALBUM_UPDATE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: true });
if (dto.albumThumbnailAssetId) {
const results = await this.albumRepository.getAssetIds(id, [dto.albumThumbnailAssetId]);
if (results.size === 0) {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update(album.id, {
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
});
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.ALBUM_DELETE, ids: [id] });
await this.albumRepository.delete(id);
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] });
const results = await addAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids },
);
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
if (firstNewAssetId) {
await this.albumRepository.update(id, {
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('album.update', { id, recipientId });
}
}
return results;
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const results = await removeAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE },
);
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
}
return results;
}
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
for (const { userId, role } of albumUsers) {
if (album.ownerId === userId) {
throw new BadRequestException('Cannot be shared with owner');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (exists) {
throw new BadRequestException('User already added');
}
const user = await this.userRepository.get(userId, {});
if (!user) {
throw new BadRequestException('User not found');
}
await this.albumUserRepository.create({ usersId: userId, albumsId: id, role });
await this.eventRepository.emit('album.invite', { id, userId });
}
return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
if (userId === 'me') {
userId = auth.user.id;
}
const album = await this.findOrFail(id, { withAssets: false });
if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (!exists) {
throw new BadRequestException('Album not shared with user');
}
// non-admin can remove themselves
if (auth.user.id !== userId) {
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
}
await this.albumUserRepository.delete({ albumsId: id, usersId: userId });
}
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role });
}
private async findOrFail(id: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options);
if (!album) {
throw new BadRequestException('Album not found');
}
return album;
}
}