mirror of
https://github.com/immich-app/immich.git
synced 2026-06-06 14:15:20 -04:00
Merge remote-tracking branch 'origin/main' into feat/yucca-integration
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,13 @@ import {
|
||||
CreateAlbumDto,
|
||||
GetAlbumsDto,
|
||||
mapAlbum,
|
||||
MapAlbumDto,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { AlbumUserRole, 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';
|
||||
@@ -26,9 +25,9 @@ import { getPreferences } from 'src/utils/preferences';
|
||||
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),
|
||||
this.albumRepository.getAll(auth.user.id, { isOwned: true }),
|
||||
this.albumRepository.getAll(auth.user.id, { isShared: true }),
|
||||
this.albumRepository.getAll(auth.user.id, { isOwned: true, isShared: false }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -38,18 +37,18 @@ export class AlbumService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
async getAll(
|
||||
{ user: { id: ownerId } }: AuthDto,
|
||||
{ assetId, isOwned, isShared }: 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);
|
||||
const albums = assetId
|
||||
? await this.albumRepository.getByAssetId(ownerId, assetId)
|
||||
: await this.albumRepository.getAll(ownerId, { isOwned, isShared });
|
||||
|
||||
if (albums.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get asset count for each album. Then map the result to an object:
|
||||
@@ -74,10 +73,10 @@ export class AlbumService extends BaseService {
|
||||
async get(auth: AuthDto, id: string): Promise<AlbumResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
|
||||
await this.albumRepository.updateThumbnails();
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
|
||||
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
|
||||
|
||||
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
|
||||
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 1;
|
||||
const hasSharedLink = album.sharedLinks && album.sharedLinks.length > 0;
|
||||
const isShared = hasSharedUsers || hasSharedLink;
|
||||
|
||||
@@ -107,7 +106,8 @@ export class AlbumService extends BaseService {
|
||||
for (const { userId } of albumUsers) {
|
||||
const exists = await this.userRepository.get(userId, {});
|
||||
if (!exists) {
|
||||
throw new BadRequestException('User not found');
|
||||
this.logger.debug('Album creation failed: user not found');
|
||||
throw new BadRequestException('Invalid user');
|
||||
}
|
||||
|
||||
if (userId == auth.user.id) {
|
||||
@@ -126,18 +126,18 @@ export class AlbumService extends BaseService {
|
||||
|
||||
const album = await this.albumRepository.create(
|
||||
{
|
||||
ownerId: auth.user.id,
|
||||
albumName: dto.albumName,
|
||||
description: dto.description,
|
||||
albumThumbnailAssetId: assetIds[0] || null,
|
||||
order: getPreferences(userMetadata).albums.defaultAssetOrder,
|
||||
},
|
||||
assetIds,
|
||||
albumUsers,
|
||||
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
for (const { userId } of albumUsers) {
|
||||
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId });
|
||||
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
|
||||
}
|
||||
|
||||
return mapAlbum(album);
|
||||
@@ -146,7 +146,7 @@ export class AlbumService extends BaseService {
|
||||
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: true });
|
||||
const album = await this.findOrFail(id, auth.user.id, { withAssets: true });
|
||||
|
||||
if (dto.albumThumbnailAssetId) {
|
||||
const results = await this.albumRepository.getAssetIds(id, [dto.albumThumbnailAssetId]);
|
||||
@@ -154,14 +154,18 @@ export class AlbumService extends BaseService {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
},
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
return mapAlbum({ ...updatedAlbum, assets: album.assets });
|
||||
}
|
||||
@@ -172,7 +176,7 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
|
||||
|
||||
const results = await addAssets(
|
||||
@@ -183,16 +187,18 @@ export class AlbumService extends BaseService {
|
||||
|
||||
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
|
||||
if (firstNewAssetId) {
|
||||
await this.albumRepository.update(id, {
|
||||
await this.albumRepository.update(
|
||||
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,
|
||||
{
|
||||
id,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
|
||||
},
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
|
||||
|
||||
for (const recipientId of allUsersExceptUs) {
|
||||
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
|
||||
}
|
||||
@@ -231,21 +237,23 @@ export class AlbumService extends BaseService {
|
||||
if (notPresentAssetIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const album = await this.findOrFail(albumId, { withAssets: false });
|
||||
const album = await this.findOrFail(albumId, auth.user.id, { withAssets: false });
|
||||
results.error = undefined;
|
||||
results.success = true;
|
||||
|
||||
for (const assetId of notPresentAssetIds) {
|
||||
albumAssetValues.push({ albumId, assetId });
|
||||
}
|
||||
await this.albumRepository.update(albumId, {
|
||||
id: albumId,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
|
||||
});
|
||||
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
|
||||
(userId) => userId !== auth.user.id,
|
||||
await this.albumRepository.update(
|
||||
albumId,
|
||||
{
|
||||
id: albumId,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
|
||||
},
|
||||
auth.user.id,
|
||||
);
|
||||
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
|
||||
events.push({ id: albumId, recipients: allUsersExceptUs });
|
||||
}
|
||||
|
||||
@@ -262,7 +270,7 @@ export class AlbumService extends BaseService {
|
||||
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
|
||||
const results = await removeAssets(
|
||||
auth,
|
||||
{ access: this.accessRepository, bulk: this.albumRepository },
|
||||
@@ -280,11 +288,11 @@ export class AlbumService extends BaseService {
|
||||
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
|
||||
|
||||
for (const { userId, role } of albumUsers) {
|
||||
if (album.ownerId === userId) {
|
||||
throw new BadRequestException('Cannot be shared with owner');
|
||||
if (role === AlbumUserRole.Owner) {
|
||||
throw new BadRequestException('Cannot add another owner');
|
||||
}
|
||||
|
||||
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
|
||||
@@ -294,14 +302,15 @@ export class AlbumService extends BaseService {
|
||||
|
||||
const user = await this.userRepository.get(userId, {});
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
this.logger.debug('Adding user to album failed: user not found');
|
||||
throw new BadRequestException('Invalid user');
|
||||
}
|
||||
|
||||
await this.albumUserRepository.create({ userId, albumId: id, role });
|
||||
await this.eventRepository.emit('AlbumInvite', { id, userId });
|
||||
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
|
||||
}
|
||||
|
||||
return this.findOrFail(id, { withAssets: true }).then(mapAlbum);
|
||||
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
|
||||
}
|
||||
|
||||
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
|
||||
@@ -309,17 +318,20 @@ export class AlbumService extends BaseService {
|
||||
userId = auth.user.id;
|
||||
}
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
|
||||
if (album.ownerId === userId) {
|
||||
throw new BadRequestException('Cannot remove album owner');
|
||||
}
|
||||
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
|
||||
|
||||
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('Album not shared with user');
|
||||
}
|
||||
|
||||
if (
|
||||
exists.role === AlbumUserRole.Owner &&
|
||||
album.albumUsers.filter(({ role }) => role === AlbumUserRole.Owner).length === 1
|
||||
) {
|
||||
throw new BadRequestException('Cannot remove the last album owner');
|
||||
}
|
||||
|
||||
// non-admin can remove themselves
|
||||
if (auth.user.id !== userId) {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
|
||||
@@ -333,8 +345,8 @@ export class AlbumService extends BaseService {
|
||||
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
|
||||
}
|
||||
|
||||
private async findOrFail(id: string, options: AlbumInfoOptions) {
|
||||
const album = await this.albumRepository.getById(id, options);
|
||||
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
|
||||
const album = await this.albumRepository.getById(id, options, authUserId);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ const validImages = [
|
||||
'.jxl',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mpo',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.orf',
|
||||
|
||||
@@ -351,10 +351,10 @@ export class AssetMediaService extends BaseService {
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId: asset.id, fileSizeInByte: file.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: { assetId: asset.id, fileSizeInByte: file.size },
|
||||
lockedPropertiesBehavior: 'override',
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('AssetCreate', { asset });
|
||||
|
||||
|
||||
@@ -187,8 +187,10 @@ describe(AssetService.name, () => {
|
||||
await sut.update(authStub.admin, asset.id, { description: 'Test description' });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: asset.id, description: 'Test description', lockedProperties: ['description'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
expect.objectContaining({
|
||||
exif: { assetId: asset.id, description: 'Test description', lockedProperties: ['description'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -201,12 +203,14 @@ describe(AssetService.name, () => {
|
||||
await sut.update(authStub.admin, asset.id, { rating: 3 });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{
|
||||
assetId: asset.id,
|
||||
rating: 3,
|
||||
lockedProperties: ['rating'],
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
expect.objectContaining({
|
||||
exif: {
|
||||
assetId: asset.id,
|
||||
rating: 3,
|
||||
lockedProperties: ['rating'],
|
||||
},
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -517,13 +517,13 @@ export class AssetService extends BaseService {
|
||||
);
|
||||
|
||||
if (Object.keys(writes).length > 0) {
|
||||
await this.assetRepository.upsertExif(
|
||||
updateLockedColumns({
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: updateLockedColumns({
|
||||
assetId: id,
|
||||
...writes,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
lockedPropertiesBehavior: 'append',
|
||||
});
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
|
||||
import { parse } from 'cookie';
|
||||
import { DateTime } from 'luxon';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||
import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
import {
|
||||
AuthDto,
|
||||
@@ -62,15 +62,12 @@ export class AuthService extends BaseService {
|
||||
throw new UnauthorizedException('Password login has been disabled');
|
||||
}
|
||||
|
||||
let user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
|
||||
if (user) {
|
||||
const isAuthenticated = this.validateSecret(dto.password, user.password);
|
||||
if (!isAuthenticated) {
|
||||
user = undefined;
|
||||
}
|
||||
}
|
||||
const user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
|
||||
// Always run bcrypt so response time is constant regardless of whether the email
|
||||
// is registered, preventing timing-based user enumeration.
|
||||
const authenticated = this.cryptoRepository.compareBcrypt(dto.password, user?.password ?? LOGIN_DUMMY_HASH);
|
||||
|
||||
if (!user) {
|
||||
if (!user || !user.password || !authenticated) {
|
||||
this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
|
||||
throw new UnauthorizedException('Incorrect email or password');
|
||||
}
|
||||
@@ -325,7 +322,8 @@ export class AuthService extends BaseService {
|
||||
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
|
||||
if (emailUser) {
|
||||
if (emailUser.oauthId) {
|
||||
throw new BadRequestException('User already exists, but is linked to another account.');
|
||||
this.logger.debug('OAuth login conflict: email already linked to different account');
|
||||
throw new BadRequestException('OAuth authentication failed');
|
||||
}
|
||||
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
|
||||
}
|
||||
@@ -335,9 +333,9 @@ export class AuthService extends BaseService {
|
||||
if (!user) {
|
||||
if (!autoRegister) {
|
||||
this.logger.warn(
|
||||
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
|
||||
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
|
||||
);
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
throw new BadRequestException('OAuth authentication failed');
|
||||
}
|
||||
|
||||
if (!normalizedEmail) {
|
||||
|
||||
@@ -53,6 +53,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
@@ -109,6 +110,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
TrashRepository,
|
||||
UserRepository,
|
||||
VersionHistoryRepository,
|
||||
VideoStreamRepository,
|
||||
ViewRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
@@ -167,6 +169,7 @@ export class BaseService {
|
||||
protected trashRepository: TrashRepository,
|
||||
protected userRepository: UserRepository,
|
||||
protected versionRepository: VersionHistoryRepository,
|
||||
protected videoStreamRepository: VideoStreamRepository,
|
||||
protected viewRepository: ViewRepository,
|
||||
protected websocketRepository: WebsocketRepository,
|
||||
protected workflowRepository: WorkflowRepository,
|
||||
@@ -215,7 +218,8 @@ export class BaseService {
|
||||
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
|
||||
const exists = await this.userRepository.getByEmail(dto.email);
|
||||
if (exists) {
|
||||
throw new BadRequestException('User exists');
|
||||
this.logger.debug('User creation rejected: user already exists');
|
||||
throw new BadRequestException('Email is not available');
|
||||
}
|
||||
|
||||
if (!dto.isAdmin) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EXTENSION_NAMES } from 'src/constants';
|
||||
import { DatabaseExtension, VectorIndex } from 'src/enum';
|
||||
import { DatabaseService } from 'src/services/database.service';
|
||||
import { VectorExtension } from 'src/types';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { envData, mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(DatabaseService.name, () => {
|
||||
@@ -55,7 +55,6 @@ describe(DatabaseService.name, () => {
|
||||
|
||||
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
|
||||
{ extension: DatabaseExtension.Vector, extensionName: EXTENSION_NAMES[DatabaseExtension.Vector] },
|
||||
{ extension: DatabaseExtension.Vectors, extensionName: EXTENSION_NAMES[DatabaseExtension.Vectors] },
|
||||
{ extension: DatabaseExtension.VectorChord, extensionName: EXTENSION_NAMES[DatabaseExtension.VectorChord] },
|
||||
])('should work with $extensionName', ({ extension, extensionName }) => {
|
||||
beforeEach(() => {
|
||||
@@ -68,20 +67,7 @@ describe(DatabaseService.name, () => {
|
||||
]);
|
||||
mocks.database.getVectorExtension.mockResolvedValue(extension);
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: false,
|
||||
vectorExtension: extension,
|
||||
},
|
||||
}),
|
||||
mockEnvData({ database: { ...envData.database, vectorExtension: extension } }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -157,7 +143,6 @@ describe(DatabaseService.name, () => {
|
||||
installedVersion: minVersionInRange,
|
||||
},
|
||||
]);
|
||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
@@ -278,27 +263,6 @@ describe(DatabaseService.name, () => {
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should warn if ${extension} extension update requires restart`, async () => {
|
||||
mocks.database.getExtensionVersions.mockResolvedValue([
|
||||
{
|
||||
name: extension,
|
||||
availableVersion: updateInRange,
|
||||
installedVersion: minVersionInRange,
|
||||
},
|
||||
]);
|
||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: true });
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.logger.warn.mock.calls).toEqual(
|
||||
expect.arrayContaining([expect.arrayContaining([expect.stringContaining(extensionName)])]),
|
||||
);
|
||||
|
||||
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should reindex ${extension} indices if needed`, async () => {
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
@@ -329,22 +293,7 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: true,
|
||||
vectorExtension: DatabaseExtension.Vectors,
|
||||
},
|
||||
}),
|
||||
);
|
||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ database: { ...envData.database, skipMigrations: true } }));
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
@@ -352,7 +301,6 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
|
||||
it(`should throw error if extension could not be created`, async () => {
|
||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
|
||||
@@ -365,35 +313,42 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
|
||||
it(`should drop unused extension`, async () => {
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
mockEnvData({ database: { ...envData.database, vectorExtension: DatabaseExtension.Vector } }),
|
||||
);
|
||||
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vector);
|
||||
mocks.database.getExtensionVersions.mockResolvedValue([
|
||||
{
|
||||
name: DatabaseExtension.Vectors,
|
||||
name: DatabaseExtension.Vector,
|
||||
installedVersion: minVersionInRange,
|
||||
availableVersion: minVersionInRange,
|
||||
},
|
||||
{
|
||||
name: DatabaseExtension.VectorChord,
|
||||
installedVersion: null,
|
||||
installedVersion: minVersionInRange,
|
||||
availableVersion: minVersionInRange,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
|
||||
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.Vectors);
|
||||
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
|
||||
});
|
||||
|
||||
it(`should warn if unused extension could not be dropped`, async () => {
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
mockEnvData({ database: { ...envData.database, vectorExtension: DatabaseExtension.Vector } }),
|
||||
);
|
||||
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vector);
|
||||
mocks.database.getExtensionVersions.mockResolvedValue([
|
||||
{
|
||||
name: DatabaseExtension.Vectors,
|
||||
name: DatabaseExtension.Vector,
|
||||
installedVersion: minVersionInRange,
|
||||
availableVersion: minVersionInRange,
|
||||
},
|
||||
{
|
||||
name: DatabaseExtension.VectorChord,
|
||||
installedVersion: null,
|
||||
installedVersion: minVersionInRange,
|
||||
availableVersion: minVersionInRange,
|
||||
},
|
||||
]);
|
||||
@@ -401,10 +356,9 @@ describe(DatabaseService.name, () => {
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
|
||||
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.Vectors);
|
||||
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
|
||||
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DROP EXTENSION vectors');
|
||||
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DROP EXTENSION vchord');
|
||||
});
|
||||
|
||||
it(`should not try to drop pgvector when using vectorchord`, async () => {
|
||||
@@ -426,21 +380,5 @@ describe(DatabaseService.name, () => {
|
||||
|
||||
expect(mocks.database.dropExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should warn if using pgvecto.rs`, async () => {
|
||||
mocks.database.getExtensionVersions.mockResolvedValue([
|
||||
{
|
||||
name: DatabaseExtension.Vectors,
|
||||
installedVersion: minVersionInRange,
|
||||
availableVersion: minVersionInRange,
|
||||
},
|
||||
]);
|
||||
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vectors);
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DEPRECATION WARNING');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import { VectorExtension } from 'src/types';
|
||||
type CreateFailedArgs = { name: string; extension: string };
|
||||
type UpdateFailedArgs = { name: string; extension: string; availableVersion: string };
|
||||
type DropFailedArgs = { name: string; extension: string };
|
||||
type RestartRequiredArgs = { name: string; availableVersion: string };
|
||||
type NightlyVersionArgs = { name: string; extension: string; version: string };
|
||||
type OutOfRangeArgs = { name: string; extension: string; version: string; range: string };
|
||||
type InvalidDowngradeArgs = { name: string; extension: string; installedVersion: string; availableVersion: string };
|
||||
@@ -46,16 +45,10 @@ const messages = {
|
||||
|
||||
Please run 'DROP EXTENSION ${extension};' manually as a superuser.
|
||||
See https://docs.immich.app/guides/database-queries for how to query the database.`,
|
||||
restartRequired: ({ name, availableVersion }: RestartRequiredArgs) =>
|
||||
`The ${name} extension has been updated to ${availableVersion}.
|
||||
Please restart the Postgres instance to complete the update.`,
|
||||
invalidDowngrade: ({ name, installedVersion, availableVersion }: InvalidDowngradeArgs) =>
|
||||
`The database currently has ${name} ${installedVersion} activated, but the Postgres instance only has ${availableVersion} available.
|
||||
This most likely means the extension was downgraded.
|
||||
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
|
||||
deprecatedExtension: (name: string) =>
|
||||
`DEPRECATION WARNING: The ${name} extension is deprecated and support for it will be removed very soon.
|
||||
See https://docs.immich.app/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -74,9 +67,6 @@ export class DatabaseService extends BaseService {
|
||||
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
|
||||
const extension = await this.databaseRepository.getVectorExtension();
|
||||
const name = EXTENSION_NAMES[extension];
|
||||
if (extension === DatabaseExtension.Vectors) {
|
||||
this.logger.warn(messages.deprecatedExtension(name));
|
||||
}
|
||||
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
|
||||
|
||||
const extensionVersions = await this.databaseRepository.getExtensionVersions(VECTOR_EXTENSIONS);
|
||||
@@ -156,10 +146,7 @@ export class DatabaseService extends BaseService {
|
||||
private async updateExtension(extension: VectorExtension, availableVersion: string) {
|
||||
this.logger.log(`Updating ${EXTENSION_NAMES[extension]} extension to ${availableVersion}`);
|
||||
try {
|
||||
const { restartRequired } = await this.databaseRepository.updateVectorExtension(extension, availableVersion);
|
||||
if (restartRequired) {
|
||||
this.logger.warn(messages.restartRequired({ name: EXTENSION_NAMES[extension], availableVersion }));
|
||||
}
|
||||
await this.databaseRepository.updateVectorExtension(extension, availableVersion);
|
||||
} catch (error) {
|
||||
this.logger.warn(messages.updateFailed({ name: EXTENSION_NAMES[extension], extension, availableVersion }));
|
||||
throw error;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
@@ -149,6 +150,36 @@ describe(DuplicateService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw for an unknown or unauthorized group id', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.delete(authStub.admin, 'group-1')).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.duplicateRepository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dismiss the duplicate group', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
mocks.duplicateRepository.delete.mockResolvedValue();
|
||||
await expect(sut.delete(authStub.admin, 'group-1')).resolves.toBeUndefined();
|
||||
expect(mocks.duplicateRepository.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should throw if any group id is unknown or unauthorized', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.duplicateRepository.deleteAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dismiss all duplicate groups', async () => {
|
||||
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
|
||||
mocks.duplicateRepository.deleteAll.mockResolvedValue();
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).resolves.toBeUndefined();
|
||||
expect(mocks.duplicateRepository.deleteAll).toHaveBeenCalledWith(authStub.admin.user.id, ['group-1', 'group-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should handle mixed success and failure', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
@@ -82,10 +82,12 @@ export class DuplicateService extends BaseService {
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: [id] });
|
||||
await this.duplicateRepository.delete(auth.user.id, id);
|
||||
}
|
||||
|
||||
async deleteAll(auth: AuthDto, dto: BulkIdsDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: dto.ids });
|
||||
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export class JobService extends BaseService {
|
||||
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
|
||||
|
||||
if (asset) {
|
||||
this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, {
|
||||
this.websocketRepository.clientSend('AssetEditReadyV2', asset.ownerId, {
|
||||
asset: {
|
||||
id: asset.id,
|
||||
ownerId: asset.ownerId,
|
||||
@@ -156,7 +156,7 @@ export class JobService extends BaseService {
|
||||
this.websocketRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset));
|
||||
if (asset.exifInfo) {
|
||||
const exif = asset.exifInfo;
|
||||
this.websocketRepository.clientSend('AssetUploadReadyV1', asset.ownerId, {
|
||||
this.websocketRepository.clientSend('AssetUploadReadyV2', asset.ownerId, {
|
||||
// TODO remove `on_upload_success` and then modify the query to select only the required fields)
|
||||
asset: {
|
||||
id: asset.id,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { getForAlbum, getForPartner } from 'test/mappers';
|
||||
import { getForPartner } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(MapService.name, () => {
|
||||
@@ -82,15 +82,15 @@ describe(MapService.name, () => {
|
||||
};
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||
mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]);
|
||||
mocks.album.getShared.mockResolvedValue([
|
||||
getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()),
|
||||
]);
|
||||
const album1 = AlbumFactory.create();
|
||||
const album2 = AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build();
|
||||
mocks.album.getAllIds.mockResolvedValue([album1.id, album2.id]);
|
||||
|
||||
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual(marker);
|
||||
expect(mocks.album.getAllIds).toHaveBeenCalledWith(auth.user.id);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,15 +13,7 @@ export class MapService extends BaseService {
|
||||
userIds.push(...partnerIds);
|
||||
}
|
||||
|
||||
// TODO convert to SQL join
|
||||
const albumIds: string[] = [];
|
||||
if (options.withSharedAlbums) {
|
||||
const [ownedAlbums, sharedAlbums] = await Promise.all([
|
||||
this.albumRepository.getOwned(auth.user.id),
|
||||
this.albumRepository.getShared(auth.user.id),
|
||||
]);
|
||||
albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id));
|
||||
}
|
||||
const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
|
||||
|
||||
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,9 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ImageFormat,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
LogLevel,
|
||||
QueueName,
|
||||
RawExtractedFormat,
|
||||
StorageFolder,
|
||||
@@ -61,10 +61,9 @@ type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForG
|
||||
export class MediaService extends BaseService {
|
||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap' })
|
||||
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
|
||||
async onBootstrap() {
|
||||
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
||||
this.videoInterfaces = { dri, mali };
|
||||
this.videoInterfaces = await this.storageCore.getVideoInterfaces();
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -506,10 +505,7 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(
|
||||
asset: ThumbnailPathEntity & { originalPath: string },
|
||||
{ ffmpeg, image }: SystemConfig,
|
||||
) {
|
||||
private async generateVideoThumbnails(asset: ThumbnailAsset, { ffmpeg, image }: SystemConfig) {
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
format: image.preview.format,
|
||||
@@ -526,22 +522,15 @@ export class MediaService extends BaseService {
|
||||
});
|
||||
this.storageCore.ensureFolders(previewFile.path);
|
||||
|
||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
if (!mainVideoStream) {
|
||||
throw new Error(`No video streams found for asset ${asset.id}`);
|
||||
const { videoStream, format } = asset;
|
||||
if (!videoStream || !format) {
|
||||
throw new Error(`Missing video metadata for asset ${asset.id}`);
|
||||
}
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
|
||||
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
||||
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
||||
const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, mainVideoStream, mainAudioStream, format);
|
||||
const thumbnailOptions = thumbnailConfig.getCommand(
|
||||
TranscodeTarget.Video,
|
||||
mainVideoStream,
|
||||
mainAudioStream,
|
||||
format,
|
||||
);
|
||||
const thumbConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
||||
const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined);
|
||||
const thumbnailOptions = thumbConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined);
|
||||
|
||||
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
|
||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions);
|
||||
@@ -554,7 +543,7 @@ export class MediaService extends BaseService {
|
||||
return {
|
||||
files: [previewFile, thumbnailFile],
|
||||
thumbhash,
|
||||
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
|
||||
fullsizeDimensions: { width: videoStream.width, height: videoStream.height },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -588,17 +577,14 @@ export class MediaService extends BaseService {
|
||||
const output = StorageCore.getEncodedVideoPath(asset);
|
||||
this.storageCore.ensureFolders(output);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
|
||||
countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs
|
||||
});
|
||||
const videoStream = this.getMainStream(videoStreams);
|
||||
const audioStream = this.getMainStream(audioStreams);
|
||||
if (!videoStream || !format.formatName) {
|
||||
const { videoStream, format } = asset;
|
||||
const audioStream = asset.audioStream ?? undefined;
|
||||
if (!videoStream || !format) {
|
||||
this.logger.warn(`Skipped transcoding for asset ${asset.id}: missing metadata; re-run extraction first`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
if (!videoStream.height || !videoStream.width) {
|
||||
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
|
||||
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video dimensions`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
@@ -667,12 +653,6 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T {
|
||||
return streams
|
||||
.filter((stream) => stream.codecName !== 'unknown')
|
||||
.toSorted((stream1, stream2) => stream2.bitrate - stream1.bitrate)[0];
|
||||
}
|
||||
|
||||
private getTranscodeTarget(
|
||||
config: SystemConfigFFmpegDto,
|
||||
videoStream: VideoStreamInfo,
|
||||
@@ -809,28 +789,6 @@ export class MediaService extends BaseService {
|
||||
return extractedSize >= targetSize;
|
||||
}
|
||||
|
||||
private async getDevices() {
|
||||
try {
|
||||
return await this.storageRepository.readdir('/dev/dri');
|
||||
} catch {
|
||||
this.logger.debug('No devices found in /dev/dri.');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async hasMaliOpenCL() {
|
||||
try {
|
||||
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
||||
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
||||
this.storageRepository.stat('/dev/mali0'),
|
||||
]);
|
||||
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} catch {
|
||||
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles(
|
||||
oldFiles: (AssetFile & { isProgressive: boolean; isTransparent: boolean })[],
|
||||
newFiles: UpsertFileOptions[],
|
||||
|
||||
@@ -28,25 +28,26 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should search memories', async () => {
|
||||
it('should search memories with assets', async () => {
|
||||
const [userId] = newUuids();
|
||||
|
||||
const asset = AssetFactory.create();
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
||||
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }),
|
||||
expect.objectContaining({ id: memory2.id, assets: [] }),
|
||||
expect.objectContaining({
|
||||
id: memory1.id,
|
||||
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map ', async () => {
|
||||
it('should map empty result', async () => {
|
||||
mocks.memory.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Memory } from 'src/database';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -71,7 +72,9 @@ export class MemoryService extends BaseService {
|
||||
|
||||
async search(auth: AuthDto, dto: MemorySearchDto) {
|
||||
const memories = await this.memoryRepository.search(auth.user.id, dto);
|
||||
return memories.map((memory) => mapMemory(memory, auth));
|
||||
return memories
|
||||
.filter((memory: Memory) => memory.assets && memory.assets.length > 0)
|
||||
.map((memory: Memory) => mapMemory(memory, auth));
|
||||
}
|
||||
|
||||
statistics(auth: AuthDto, dto: MemorySearchDto) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { videoInfoStub } from 'test/fixtures/media.stub';
|
||||
import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
@@ -59,6 +59,15 @@ const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: Immich
|
||||
},
|
||||
});
|
||||
|
||||
const emptyPackets = {
|
||||
totalDuration: 0,
|
||||
packetCount: 0,
|
||||
outputFrames: 0,
|
||||
keyframePts: [],
|
||||
keyframeAccDuration: [],
|
||||
keyframeOwnDuration: [],
|
||||
};
|
||||
|
||||
describe(MetadataService.name, () => {
|
||||
let sut: MetadataService;
|
||||
let mocks: ServiceMocks;
|
||||
@@ -183,9 +192,12 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), {
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ dateTimeOriginal: sidecarDate }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: asset.id,
|
||||
@@ -212,8 +224,10 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
@@ -242,8 +256,10 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
@@ -265,9 +281,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
|
||||
exif: expect.objectContaining({
|
||||
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
@@ -290,9 +308,12 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), {
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ iso: 160 }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
@@ -323,8 +344,10 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: null, state: null, country: null }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ city: null, state: null, country: null }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
@@ -353,8 +376,10 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
@@ -378,8 +403,10 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ latitude: null, longitude: null }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ latitude: null, longitude: null }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -585,7 +612,7 @@ describe(MetadataService.name, () => {
|
||||
it('should not apply motion photos if asset is video', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.matroskaContainer);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
@@ -611,15 +638,144 @@ describe(MetadataService.name, () => {
|
||||
it('should extract the correct video orientation', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamVertical2160p);
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist CICP smallints and profile/level for HDR10 video', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10);
|
||||
mocks.media.probePackets.mockResolvedValue(emptyPackets);
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ fps: 59.94 }),
|
||||
video: expect.objectContaining({
|
||||
codecName: 'hevc',
|
||||
profile: 2,
|
||||
level: 153,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
colorPrimaries: 9,
|
||||
colorTransfer: 16,
|
||||
colorMatrix: 9,
|
||||
dvProfile: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist Dolby Vision fields', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamDolbyVision);
|
||||
mocks.media.probePackets.mockResolvedValue(emptyPackets);
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
video: expect.objectContaining({
|
||||
dvProfile: 8,
|
||||
dvLevel: 10,
|
||||
dvBlSignalCompatibilityId: 4,
|
||||
colorTransfer: 18, // ARIB_STD_B67
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist packet-derived HLS fields', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10);
|
||||
mocks.media.probePackets.mockResolvedValue({
|
||||
totalDuration: 12_080,
|
||||
packetCount: 1148,
|
||||
outputFrames: 1149,
|
||||
keyframePts: [-590, 10, 611, 1211],
|
||||
keyframeAccDuration: [10, 610, 6110, 12_080],
|
||||
keyframeOwnDuration: [10, 10, 10, 10],
|
||||
});
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
video: expect.objectContaining({ timeBase: 600 }),
|
||||
keyframes: expect.objectContaining({
|
||||
totalDuration: 12_080,
|
||||
packetCount: 1148,
|
||||
outputFrames: 1149,
|
||||
pts: [-590, 10, 611, 1211],
|
||||
accDuration: [10, 610, 6110, 12_080],
|
||||
ownDuration: [10, 10, 10, 10],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should omit the keyframe row when the probe returns no keyframes', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10);
|
||||
mocks.media.probePackets.mockResolvedValue(emptyPackets);
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ keyframes: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer ffprobe frameRate over exiftool VideoFrameRate', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10);
|
||||
mocks.media.probePackets.mockResolvedValue(emptyPackets);
|
||||
mockReadTags({ VideoFrameRate: '30' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining({ fps: 59.94 }),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not insert audio/video/keyframe rows for image assets', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Image });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.media.probe).not.toHaveBeenCalled();
|
||||
expect(mocks.media.probePackets).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
audio: expect.anything(),
|
||||
video: expect.anything(),
|
||||
keyframes: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -909,39 +1065,41 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{
|
||||
assetId: asset.id,
|
||||
bitsPerSample: expect.any(Number),
|
||||
autoStackId: null,
|
||||
colorspace: tags.ColorSpace,
|
||||
dateTimeOriginal: dateForTest,
|
||||
description: tags.ImageDescription,
|
||||
exifImageHeight: null,
|
||||
exifImageWidth: null,
|
||||
exposureTime: tags.ExposureTime,
|
||||
fNumber: null,
|
||||
fileSizeInByte: 123_456,
|
||||
focalLength: tags.FocalLength,
|
||||
fps: null,
|
||||
iso: tags.ISO,
|
||||
latitude: null,
|
||||
lensModel: tags.LensModel,
|
||||
livePhotoCID: tags.MediaGroupUUID,
|
||||
longitude: null,
|
||||
make: tags.Make,
|
||||
model: tags.Model,
|
||||
modifyDate: expect.any(Date),
|
||||
orientation: tags.Orientation?.toString(),
|
||||
profileDescription: tags.ProfileDescription,
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
timeZone: tags.zone,
|
||||
rating: tags.Rating,
|
||||
country: null,
|
||||
state: null,
|
||||
city: null,
|
||||
tags: ['parent/child'],
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
expect.objectContaining({
|
||||
exif: {
|
||||
assetId: asset.id,
|
||||
bitsPerSample: expect.any(Number),
|
||||
autoStackId: null,
|
||||
colorspace: tags.ColorSpace,
|
||||
dateTimeOriginal: dateForTest,
|
||||
description: tags.ImageDescription,
|
||||
exifImageHeight: null,
|
||||
exifImageWidth: null,
|
||||
exposureTime: tags.ExposureTime,
|
||||
fNumber: null,
|
||||
fileSizeInByte: 123_456,
|
||||
focalLength: tags.FocalLength,
|
||||
fps: null,
|
||||
iso: tags.ISO,
|
||||
latitude: null,
|
||||
lensModel: tags.LensModel,
|
||||
livePhotoCID: tags.MediaGroupUUID,
|
||||
longitude: null,
|
||||
make: tags.Make,
|
||||
model: tags.Model,
|
||||
modifyDate: expect.any(Date),
|
||||
orientation: tags.Orientation?.toString(),
|
||||
profileDescription: tags.ProfileDescription,
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
timeZone: tags.zone,
|
||||
rating: tags.Rating,
|
||||
country: null,
|
||||
state: null,
|
||||
city: null,
|
||||
tags: ['parent/child'],
|
||||
},
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -975,9 +1133,11 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeZone: 'UTC+0',
|
||||
exif: expect.objectContaining({
|
||||
timeZone: 'UTC+0',
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -985,9 +1145,9 @@ describe(MetadataService.name, () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
...videoInfoStub.videoStreamH264,
|
||||
format: {
|
||||
...probeStub.videoStreamH264.format,
|
||||
...videoInfoStub.videoStreamH264.format,
|
||||
duration: 6.21,
|
||||
},
|
||||
});
|
||||
@@ -999,7 +1159,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: asset.id,
|
||||
duration: '00:00:06.210',
|
||||
duration: 6210,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1008,9 +1168,9 @@ describe(MetadataService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
...videoInfoStub.videoStreamH264,
|
||||
format: {
|
||||
...probeStub.videoStreamH264.format,
|
||||
...videoInfoStub.videoStreamH264.format,
|
||||
duration: 6.21,
|
||||
},
|
||||
});
|
||||
@@ -1030,9 +1190,9 @@ describe(MetadataService.name, () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
...videoInfoStub.videoStreamH264,
|
||||
format: {
|
||||
...probeStub.videoStreamH264.format,
|
||||
...videoInfoStub.videoStreamH264.format,
|
||||
duration: 0,
|
||||
},
|
||||
});
|
||||
@@ -1053,9 +1213,9 @@ describe(MetadataService.name, () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
...videoInfoStub.videoStreamH264,
|
||||
format: {
|
||||
...probeStub.videoStreamH264.format,
|
||||
...videoInfoStub.videoStreamH264.format,
|
||||
duration: 604_800,
|
||||
},
|
||||
});
|
||||
@@ -1067,7 +1227,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: asset.id,
|
||||
duration: '168:00:00.000',
|
||||
duration: 604_800_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1080,7 +1240,7 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 123_000 }));
|
||||
});
|
||||
|
||||
it('should prefer Duration from exif over sidecar', async () => {
|
||||
@@ -1092,7 +1252,7 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 123_000 }));
|
||||
});
|
||||
|
||||
it('should ignore all Duration tags for definitely static images', async () => {
|
||||
@@ -1111,9 +1271,9 @@ describe(MetadataService.name, () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Duration: 123 }, {});
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
...videoInfoStub.videoStreamH264,
|
||||
format: {
|
||||
...probeStub.videoStreamH264.format,
|
||||
...videoInfoStub.videoStreamH264.format,
|
||||
duration: 456,
|
||||
},
|
||||
});
|
||||
@@ -1121,7 +1281,7 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:07:36.000' }));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 456_000 }));
|
||||
});
|
||||
|
||||
it('should trim whitespace from description', async () => {
|
||||
@@ -1132,18 +1292,22 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: '',
|
||||
exif: expect.objectContaining({
|
||||
description: '',
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
|
||||
mockReadTags({ ImageDescription: ' my\n description' });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: 'my\n description',
|
||||
exif: expect.objectContaining({
|
||||
description: 'my\n description',
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1155,9 +1319,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: '1000',
|
||||
exif: expect.objectContaining({
|
||||
description: '1000',
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1388,9 +1554,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modifyDate: expect.any(Date),
|
||||
exif: expect.objectContaining({
|
||||
modifyDate: expect.any(Date),
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1402,9 +1570,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: null,
|
||||
exif: expect.objectContaining({
|
||||
rating: null,
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1416,9 +1586,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: 5,
|
||||
exif: expect.objectContaining({
|
||||
rating: 5,
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1430,9 +1602,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: null,
|
||||
exif: expect.objectContaining({
|
||||
rating: null,
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1444,9 +1618,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: -1,
|
||||
exif: expect.objectContaining({
|
||||
rating: -1,
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1466,7 +1642,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle not finding a match', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamVertical2160p);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
@@ -1578,9 +1754,12 @@ describe(MetadataService.name, () => {
|
||||
mockReadTags(exif);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), {
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: expect.objectContaining(expected),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1605,9 +1784,11 @@ describe(MetadataService.name, () => {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lensModel: expected,
|
||||
exif: expect.objectContaining({
|
||||
lensModel: expected,
|
||||
}),
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -243,10 +243,11 @@ export class MetadataService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const [exifTags, stats] = await Promise.all([
|
||||
const [exifResult, stats] = await Promise.all([
|
||||
this.getExifTags(asset),
|
||||
this.storageRepository.stat(asset.originalPath),
|
||||
]);
|
||||
const { tags: exifTags, audio, video, packets, format } = exifResult;
|
||||
this.logger.verbose('Exif Tags', exifTags);
|
||||
|
||||
const dates = this.getDates(asset, exifTags, stats);
|
||||
@@ -294,7 +295,7 @@ export class MetadataService extends BaseService {
|
||||
exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? (exifTags.DeviceManufacturer || null),
|
||||
model:
|
||||
exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? (exifTags.DeviceModelName || null),
|
||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||
fps: video?.frameRate ?? validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||
iso: validate(exifTags.ISO) as number,
|
||||
exposureTime: exifTags.ExposureTime ?? null,
|
||||
lensModel: getLensModel(exifTags),
|
||||
@@ -313,6 +314,53 @@ export class MetadataService extends BaseService {
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
};
|
||||
|
||||
const audioData =
|
||||
format && audio?.codecName
|
||||
? {
|
||||
assetId: asset.id,
|
||||
bitrate: audio.bitrate,
|
||||
index: audio.index,
|
||||
profile: audio.profile,
|
||||
codecName: audio.codecName,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const videoData =
|
||||
format?.formatName && format?.formatLongName && video?.codecName && video?.timeBase
|
||||
? {
|
||||
assetId: asset.id,
|
||||
bitrate: video.bitrate,
|
||||
frameCount: video.frameCount,
|
||||
timeBase: video.timeBase,
|
||||
index: video.index,
|
||||
profile: video.profile,
|
||||
level: video.level,
|
||||
colorPrimaries: video.colorPrimaries,
|
||||
colorTransfer: video.colorTransfer,
|
||||
colorMatrix: video.colorMatrix,
|
||||
dvProfile: video.dvProfile,
|
||||
dvLevel: video.dvLevel,
|
||||
dvBlSignalCompatibilityId: video.dvBlSignalCompatibilityId,
|
||||
codecName: video.codecName,
|
||||
formatName: format.formatName,
|
||||
formatLongName: format.formatLongName,
|
||||
pixelFormat: video.pixelFormat,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const keyframeData =
|
||||
packets && packets.keyframePts.length > 0
|
||||
? {
|
||||
assetId: asset.id,
|
||||
totalDuration: packets.totalDuration,
|
||||
packetCount: packets.packetCount,
|
||||
outputFrames: packets.outputFrames,
|
||||
pts: packets.keyframePts,
|
||||
accDuration: packets.keyframeAccDuration,
|
||||
ownDuration: packets.keyframeOwnDuration,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
|
||||
const assetWidth = isSidewards ? validate(height) : validate(width);
|
||||
const assetHeight = isSidewards ? validate(width) : validate(height);
|
||||
@@ -333,7 +381,13 @@ export class MetadataService extends BaseService {
|
||||
height: !asset.isEdited || asset.height == null ? assetHeight : undefined,
|
||||
}),
|
||||
async () => {
|
||||
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: exifData,
|
||||
audio: audioData,
|
||||
video: videoData,
|
||||
keyframes: keyframeData,
|
||||
lockedPropertiesBehavior: 'skip',
|
||||
});
|
||||
await this.applyTagList(asset);
|
||||
},
|
||||
);
|
||||
@@ -523,13 +577,14 @@ export class MetadataService extends BaseService {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
|
||||
private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }) {
|
||||
const { sidecarFile } = getAssetFiles(asset.files);
|
||||
const shouldProbe = asset.type === AssetType.Video || asset.originalPath.toLowerCase().endsWith('.gif');
|
||||
|
||||
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
|
||||
const [mediaTags, sidecarTags, videoResult] = await Promise.all([
|
||||
this.metadataRepository.readTags(asset.originalPath),
|
||||
sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null,
|
||||
asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null,
|
||||
shouldProbe ? this.getVideoTags(asset.originalPath) : null,
|
||||
]);
|
||||
|
||||
// prefer dates from sidecar tags
|
||||
@@ -554,14 +609,20 @@ export class MetadataService extends BaseService {
|
||||
|
||||
// prefer duration from video tags
|
||||
// don't save duration if asset is definitely not an animated image (see e.g. CR3 with Duration: 1s)
|
||||
if (videoTags || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) {
|
||||
if (videoResult || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) {
|
||||
delete mediaTags.Duration;
|
||||
}
|
||||
|
||||
// never use duration from sidecar
|
||||
delete sidecarTags?.Duration;
|
||||
|
||||
return { ...mediaTags, ...videoTags, ...sidecarTags };
|
||||
return {
|
||||
tags: { ...mediaTags, ...videoResult?.tags, ...sidecarTags },
|
||||
audio: videoResult?.audio,
|
||||
video: videoResult?.video,
|
||||
packets: videoResult?.packets,
|
||||
format: videoResult?.format ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private getTagList(exifTags: ImmichTags): string[] {
|
||||
@@ -1001,35 +1062,29 @@ export class MetadataService extends BaseService {
|
||||
return bitsPerSample;
|
||||
}
|
||||
|
||||
private getDuration(tags: ImmichTags): string | null {
|
||||
private getDuration(tags: ImmichTags): number | null {
|
||||
const duration = tags.Duration;
|
||||
|
||||
if (typeof duration === 'string') {
|
||||
return duration;
|
||||
}
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return Duration.fromObject({ seconds: duration }).toFormat('hh:mm:ss.SSS');
|
||||
}
|
||||
|
||||
return null;
|
||||
const seconds = typeof duration === 'number' ? duration : Number.parseFloat(duration as string);
|
||||
return Number.isFinite(seconds) ? Math.round(Duration.fromObject({ seconds }).toMillis()) : null;
|
||||
}
|
||||
|
||||
private async getVideoTags(originalPath: string) {
|
||||
const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(originalPath);
|
||||
const video = videoStreams[0];
|
||||
const audio = audioStreams[0];
|
||||
const packets = video?.timeBase ? await this.mediaRepository.probePackets(originalPath, video.index) : null;
|
||||
|
||||
const tags: Pick<ImmichTags, 'Duration' | 'Orientation' | 'ImageWidth' | 'ImageHeight'> = {};
|
||||
|
||||
if (videoStreams[0]) {
|
||||
// Set video dimensions
|
||||
if (videoStreams[0].width) {
|
||||
tags.ImageWidth = videoStreams[0].width;
|
||||
if (video) {
|
||||
if (video.width) {
|
||||
tags.ImageWidth = video.width;
|
||||
}
|
||||
if (videoStreams[0].height) {
|
||||
tags.ImageHeight = videoStreams[0].height;
|
||||
if (video.height) {
|
||||
tags.ImageHeight = video.height;
|
||||
}
|
||||
|
||||
switch (videoStreams[0].rotation) {
|
||||
switch (video.rotation) {
|
||||
case -90: {
|
||||
tags.Orientation = ExifOrientation.Rotate90CW;
|
||||
break;
|
||||
@@ -1053,6 +1108,6 @@ export class MetadataService extends BaseService {
|
||||
tags.Duration = format.duration;
|
||||
}
|
||||
|
||||
return tags;
|
||||
return { tags, audio, video, packets, format };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,10 +168,10 @@ describe(NotificationService.name, () => {
|
||||
|
||||
describe('onAlbumInviteEvent', () => {
|
||||
it('should queue notify album invite event', async () => {
|
||||
await sut.onAlbumInvite({ id: '', userId: '42' });
|
||||
await sut.onAlbumInvite({ id: '', userId: '42', senderName: 'foo' });
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NotifyAlbumInvite,
|
||||
data: { id: '', recipientId: '42' },
|
||||
data: { id: '', recipientId: '42', senderName: 'foo' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -264,14 +264,18 @@ describe(NotificationService.name, () => {
|
||||
|
||||
describe('handleAlbumInvite', () => {
|
||||
it('should skip if album could not be found', async () => {
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Skipped,
|
||||
);
|
||||
expect(mocks.user.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if recipient could not be found', async () => {
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Skipped,
|
||||
);
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -288,7 +292,9 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Skipped,
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip if the recipient has email notifications for album invite disabled', async () => {
|
||||
@@ -304,7 +310,9 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Skipped,
|
||||
);
|
||||
});
|
||||
|
||||
it('should send invite email', async () => {
|
||||
@@ -322,7 +330,9 @@ describe(NotificationService.name, () => {
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SendMail,
|
||||
data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }),
|
||||
@@ -346,7 +356,9 @@ describe(NotificationService.name, () => {
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
album.albumThumbnailAssetId,
|
||||
AssetFileType.Thumbnail,
|
||||
@@ -378,7 +390,9 @@ describe(NotificationService.name, () => {
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetFile]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
album.albumThumbnailAssetId,
|
||||
AssetFileType.Thumbnail,
|
||||
@@ -412,7 +426,9 @@ describe(NotificationService.name, () => {
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([asset.files[0]]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
album.albumThumbnailAssetId,
|
||||
AssetFileType.Thumbnail,
|
||||
@@ -434,7 +450,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if owner could not be found', async () => {
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create({ ownerId: 'non-existent' })));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.from().owner({ id: 'non-existent' }).build()));
|
||||
|
||||
await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped);
|
||||
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
||||
@@ -443,7 +459,6 @@ describe(NotificationService.name, () => {
|
||||
it('should skip recipient that could not be looked up', async () => {
|
||||
const album = AlbumFactory.from().albumUser({ userId: 'non-existent' }).build();
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValueOnce(album.owner);
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||
|
||||
@@ -226,8 +226,8 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AlbumInvite' })
|
||||
async onAlbumInvite({ id, userId }: ArgOf<'AlbumInvite'>) {
|
||||
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId } });
|
||||
async onAlbumInvite({ id, userId, senderName }: ArgOf<'AlbumInvite'>) {
|
||||
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId, senderName } });
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'SessionDelete' })
|
||||
@@ -303,7 +303,7 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.NotifyAlbumInvite, queue: QueueName.Notification })
|
||||
async handleAlbumInvite({ id, recipientId }: JobOf<JobName.NotifyAlbumInvite>) {
|
||||
async handleAlbumInvite({ id, recipientId, senderName }: JobOf<JobName.NotifyAlbumInvite>) {
|
||||
const album = await this.albumRepository.getById(id, { withAssets: false });
|
||||
if (!album) {
|
||||
return JobStatus.Skipped;
|
||||
@@ -314,7 +314,7 @@ export class NotificationService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, album.owner.name);
|
||||
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, senderName);
|
||||
|
||||
const { emailNotifications } = getPreferences(recipient.metadata);
|
||||
|
||||
@@ -331,7 +331,7 @@ export class NotificationService extends BaseService {
|
||||
baseUrl: getExternalDomain(server),
|
||||
albumId: album.id,
|
||||
albumName: album.albumName,
|
||||
senderName: album.owner.name,
|
||||
senderName,
|
||||
recipientName: recipient.name,
|
||||
cid: attachment ? attachment.cid : undefined,
|
||||
},
|
||||
@@ -360,8 +360,8 @@ export class NotificationService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const owner = await this.userRepository.get(album.ownerId, { withDeleted: false });
|
||||
if (!owner) {
|
||||
const recipient = await this.userRepository.get(recipientId, { withDeleted: false });
|
||||
if (!recipient) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,8 @@ export class SharedLinkService extends BaseService {
|
||||
|
||||
private handleError(error: unknown): never {
|
||||
if ((error as PostgresError).constraint_name === 'shared_link_slug_uq') {
|
||||
throw new BadRequestException('Shared link with this slug already exists');
|
||||
this.logger.debug('Shared link with this slug already exists');
|
||||
throw new BadRequestException('Failed to save shared link');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
SyncAckDeleteDto,
|
||||
SyncAckSetDto,
|
||||
syncAssetFaceV2ToV1,
|
||||
SyncAssetV1,
|
||||
syncAlbumV2ToV1,
|
||||
SyncAssetV2,
|
||||
SyncItem,
|
||||
SyncStreamDto,
|
||||
} from 'src/dtos/sync.dto';
|
||||
@@ -21,7 +21,7 @@ import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { fromAck, serialize, SerializeOptions, toAck } from 'src/utils/sync';
|
||||
|
||||
type CheckpointMap = Partial<Record<SyncEntityType, SyncAck>>;
|
||||
type AssetLike = Omit<SyncAssetV1, 'checksum' | 'thumbhash'> & {
|
||||
type AssetLike = Omit<SyncAssetV2, 'checksum' | 'thumbhash'> & {
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
thumbhash: Buffer<ArrayBufferLike> | null;
|
||||
};
|
||||
@@ -30,7 +30,7 @@ const COMPLETE_ID = 'complete';
|
||||
const MAX_DAYS = 30;
|
||||
const MAX_DURATION = Duration.fromObject({ days: MAX_DAYS });
|
||||
|
||||
const mapSyncAssetV1 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV1 => ({
|
||||
const mapSyncAssetV2 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV2 => ({
|
||||
...data,
|
||||
checksum: hexOrBufferToBase64(checksum),
|
||||
thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
|
||||
@@ -55,11 +55,15 @@ export const SYNC_TYPES_ORDER = [
|
||||
SyncRequestType.UsersV1,
|
||||
SyncRequestType.PartnersV1,
|
||||
SyncRequestType.AssetsV1,
|
||||
SyncRequestType.AssetsV2,
|
||||
SyncRequestType.StacksV1,
|
||||
SyncRequestType.PartnerAssetsV1,
|
||||
SyncRequestType.PartnerAssetsV2,
|
||||
SyncRequestType.PartnerStacksV1,
|
||||
SyncRequestType.AlbumAssetsV1,
|
||||
SyncRequestType.AlbumAssetsV2,
|
||||
SyncRequestType.AlbumsV1,
|
||||
SyncRequestType.AlbumsV2,
|
||||
SyncRequestType.AlbumUsersV1,
|
||||
SyncRequestType.AlbumToAssetsV1,
|
||||
SyncRequestType.AssetExifsV1,
|
||||
@@ -154,19 +158,26 @@ export class SyncService extends BaseService {
|
||||
const options: SyncQueryOptions = { nowId, userId: auth.user.id };
|
||||
|
||||
const handlers: Record<SyncRequestType, () => Promise<void>> = {
|
||||
// deprecated handlers
|
||||
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(),
|
||||
[SyncRequestType.AssetFacesV1]: () => this.syncAssetFacesV1(),
|
||||
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(),
|
||||
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(),
|
||||
|
||||
[SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap),
|
||||
[SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap),
|
||||
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetsV2]: () => this.syncAssetsV2(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.PartnerAssetsV2]: () => this.syncPartnerAssetsV2(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
|
||||
[SyncRequestType.PartnerAssetExifsV1]: () =>
|
||||
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AlbumsV2]: () => this.syncAlbumsV2(options, response, checkpointMap),
|
||||
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumAssetsV2]: () => this.syncAlbumAssetsV2(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumAssetExifsV1]: () =>
|
||||
this.syncAlbumAssetExifsV1(options, response, checkpointMap, session.id),
|
||||
@@ -175,13 +186,12 @@ export class SyncService extends BaseService {
|
||||
[SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap),
|
||||
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap),
|
||||
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
|
||||
};
|
||||
} as const;
|
||||
|
||||
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
|
||||
const handler = handlers[type];
|
||||
const handler = handlers[type as keyof typeof handlers];
|
||||
await handler();
|
||||
}
|
||||
|
||||
@@ -257,21 +267,31 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAssetsV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
private syncAssetsV1(): Promise<void> {
|
||||
throw new BadRequestException('SyncRequestType.AssetsV1 is deprecated, use SyncRequestType.AssetsV2 instead');
|
||||
}
|
||||
|
||||
private async syncAssetsV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
const deleteType = SyncEntityType.AssetDeleteV1;
|
||||
const deletes = this.syncRepository.asset.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
||||
for await (const { id, ...data } of deletes) {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.AssetV1;
|
||||
const upsertType = SyncEntityType.AssetV2;
|
||||
const upserts = this.syncRepository.asset.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
|
||||
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) });
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPartnerAssetsV1(
|
||||
private syncPartnerAssetsV1(): Promise<void> {
|
||||
throw new BadRequestException(
|
||||
'SyncRequestType.PartnerAssetsV1 is deprecated, use SyncRequestType.PartnerAssetsV2 instead',
|
||||
);
|
||||
}
|
||||
|
||||
private async syncPartnerAssetsV2(
|
||||
options: SyncQueryOptions,
|
||||
response: Writable,
|
||||
checkpointMap: CheckpointMap,
|
||||
@@ -283,13 +303,13 @@ export class SyncService extends BaseService {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const backfillType = SyncEntityType.PartnerAssetBackfillV1;
|
||||
const backfillType = SyncEntityType.PartnerAssetBackfillV2;
|
||||
const backfillCheckpoint = checkpointMap[backfillType];
|
||||
const partners = await this.syncRepository.partner.getCreatedAfter({
|
||||
...options,
|
||||
afterCreateId: backfillCheckpoint?.updateId,
|
||||
});
|
||||
const upsertType = SyncEntityType.PartnerAssetV1;
|
||||
const upsertType = SyncEntityType.PartnerAssetV2;
|
||||
const upsertCheckpoint = checkpointMap[upsertType];
|
||||
if (upsertCheckpoint) {
|
||||
const endId = upsertCheckpoint.updateId;
|
||||
@@ -310,7 +330,7 @@ export class SyncService extends BaseService {
|
||||
send(response, {
|
||||
type: backfillType,
|
||||
ids: [createId, updateId],
|
||||
data: mapSyncAssetV1(data),
|
||||
data: mapSyncAssetV2(data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,7 +346,7 @@ export class SyncService extends BaseService {
|
||||
|
||||
const upserts = this.syncRepository.partnerAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
|
||||
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +432,21 @@ export class SyncService extends BaseService {
|
||||
|
||||
const upsertType = SyncEntityType.AlbumV1;
|
||||
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
const albumUsers = await this.syncRepository.album.getAlbumUsers(data.id);
|
||||
send(response, { type: upsertType, ids: [updateId], data: syncAlbumV2ToV1(data, albumUsers) });
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAlbumsV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
const deleteType = SyncEntityType.AlbumDeleteV1;
|
||||
const deletes = this.syncRepository.album.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
||||
for await (const { id, ...data } of deletes) {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.AlbumV2;
|
||||
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data });
|
||||
}
|
||||
@@ -472,20 +507,26 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAlbumAssetsV1(
|
||||
private syncAlbumAssetsV1(): Promise<void> {
|
||||
throw new BadRequestException(
|
||||
'SyncRequestType.AlbumAssetsV1 is deprecated, use SyncRequestType.AlbumAssetsV2 instead',
|
||||
);
|
||||
}
|
||||
|
||||
private async syncAlbumAssetsV2(
|
||||
options: SyncQueryOptions,
|
||||
response: Writable,
|
||||
checkpointMap: CheckpointMap,
|
||||
sessionId: string,
|
||||
) {
|
||||
const backfillType = SyncEntityType.AlbumAssetBackfillV1;
|
||||
const backfillType = SyncEntityType.AlbumAssetBackfillV2;
|
||||
const backfillCheckpoint = checkpointMap[backfillType];
|
||||
const albums = await this.syncRepository.album.getCreatedAfter({
|
||||
...options,
|
||||
afterCreateId: backfillCheckpoint?.updateId,
|
||||
});
|
||||
const updateType = SyncEntityType.AlbumAssetUpdateV1;
|
||||
const createType = SyncEntityType.AlbumAssetCreateV1;
|
||||
const updateType = SyncEntityType.AlbumAssetUpdateV2;
|
||||
const createType = SyncEntityType.AlbumAssetCreateV2;
|
||||
const updateCheckpoint = checkpointMap[updateType];
|
||||
const createCheckpoint = checkpointMap[createType];
|
||||
if (createCheckpoint) {
|
||||
@@ -504,7 +545,7 @@ export class SyncService extends BaseService {
|
||||
);
|
||||
|
||||
for await (const { updateId, ...data } of backfill) {
|
||||
send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV1(data) });
|
||||
send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data) });
|
||||
}
|
||||
|
||||
sendEntityBackfillCompleteAck(response, backfillType, createId);
|
||||
@@ -523,7 +564,7 @@ export class SyncService extends BaseService {
|
||||
createCheckpoint,
|
||||
);
|
||||
for await (const { updateId, ...data } of updates) {
|
||||
send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV1(data) });
|
||||
send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV2(data) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,12 +575,12 @@ export class SyncService extends BaseService {
|
||||
send(response, {
|
||||
type: SyncEntityType.SyncAckV1,
|
||||
data: {},
|
||||
ackType: SyncEntityType.AlbumAssetUpdateV1,
|
||||
ackType: SyncEntityType.AlbumAssetUpdateV2,
|
||||
ids: [options.nowId],
|
||||
});
|
||||
first = false;
|
||||
}
|
||||
send(response, { type: createType, ids: [updateId], data: mapSyncAssetV1(data) });
|
||||
send(response, { type: createType, ids: [updateId], data: mapSyncAssetV2(data) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,19 +825,10 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAssetFacesV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
const deleteType = SyncEntityType.AssetFaceDeleteV1;
|
||||
const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
||||
for await (const { id, ...data } of deletes) {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.AssetFaceV1;
|
||||
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
const v1 = syncAssetFaceV2ToV1(data);
|
||||
send(response, { type: upsertType, ids: [updateId], data: v1 });
|
||||
}
|
||||
private syncAssetFacesV1(): Promise<void> {
|
||||
throw new BadRequestException(
|
||||
'SyncRequestType.AssetFacesV1 is deprecated, use SyncRequestType.AssetFacesV2 instead',
|
||||
);
|
||||
}
|
||||
|
||||
private async syncAssetFacesV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
|
||||
@@ -206,16 +206,22 @@ describe(TagService.name, () => {
|
||||
count: 6,
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
@@ -255,12 +261,16 @@ describe(TagService.name, () => {
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
||||
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
|
||||
|
||||
@@ -152,7 +152,8 @@ export class TagService extends BaseService {
|
||||
|
||||
private async updateTags(assetId: string) {
|
||||
const { tags } = await this.assetRepository.getForUpdateTags(assetId);
|
||||
await this.assetRepository.upsertExif(updateLockedColumns({ assetId, tags: tags.map(({ value }) => value) }), {
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: updateLockedColumns({ assetId, tags: tags.map(({ value }) => value) }),
|
||||
lockedPropertiesBehavior: 'append',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ export class UserAdminService extends BaseService {
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Email already in use by another account');
|
||||
this.logger.debug('Email already in use by another account');
|
||||
throw new BadRequestException('Email is not available');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ describe(UserService.name, () => {
|
||||
it('should throw an error if the user does not exist', async () => {
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,8 @@ export class UserService extends BaseService {
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
if (duplicate && duplicate.id !== user.id) {
|
||||
throw new BadRequestException('Email already in use by another account');
|
||||
this.logger.warn('Email already in use by another account');
|
||||
throw new BadRequestException('Email is not available');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +135,10 @@ export class UserService extends BaseService {
|
||||
}
|
||||
|
||||
async getProfileImage(id: string): Promise<ImmichFileResponse> {
|
||||
const user = await this.findOrFail(id, {});
|
||||
if (!user.profileImagePath) {
|
||||
throw new NotFoundException('User does not have a profile image');
|
||||
const user = await this.userRepository.get(id, {});
|
||||
if (!user || !user.profileImagePath) {
|
||||
this.logger.debug('User or profile image not found');
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
|
||||
Reference in New Issue
Block a user