wip
This commit is contained in:
@@ -23,7 +23,6 @@ import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
@@ -370,8 +369,8 @@ describe(AssetMediaService.name, () => {
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 0,
|
||||
};
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.create.mockRejectedValue(error);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
|
||||
@@ -397,8 +396,8 @@ describe(AssetMediaService.name, () => {
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 0,
|
||||
};
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.create.mockRejectedValue(error);
|
||||
|
||||
@@ -480,7 +479,6 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
@@ -512,7 +510,6 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
@@ -618,7 +615,6 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
@@ -670,8 +666,6 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('replaceAsset', () => {
|
||||
it('should error when update photo does not exist', async () => {
|
||||
assetMock.getById.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow(
|
||||
'Not found or no asset.update access',
|
||||
);
|
||||
@@ -785,8 +779,8 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should handle a photo with sidecar to duplicate photo ', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.update.mockRejectedValue(error);
|
||||
assetMock.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
|
||||
@@ -30,7 +30,6 @@ import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
export interface UploadRequest {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
@@ -302,7 +301,7 @@ export class AssetMediaService extends BaseService {
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) {
|
||||
if (error.constraint_name === ASSET_CHECKSUM_CONSTRAINT) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
@@ -343,7 +342,7 @@ export class AssetMediaService extends BaseService {
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
duration: dto.duration || null,
|
||||
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
sidecarPath: sidecarPath || null,
|
||||
});
|
||||
|
||||
|
||||
@@ -51,9 +51,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
const mockGetById = (assets: AssetEntity[]) => {
|
||||
assetMock.getById.mockImplementation((assetId) =>
|
||||
Promise.resolve(assets.find((asset) => asset.id === assetId) ?? null),
|
||||
);
|
||||
assetMock.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -250,27 +248,34 @@ describe(AssetService.name, () => {
|
||||
it('should update the asset', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
assetMock.update.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
|
||||
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
|
||||
});
|
||||
|
||||
it('should update the exif description', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
assetMock.update.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
|
||||
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
|
||||
});
|
||||
|
||||
it('should update the exif rating', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.image);
|
||||
assetMock.update.mockResolvedValueOnce(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { rating: 3 });
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
|
||||
});
|
||||
|
||||
it('should fail linking a live video if the motion part could not be found', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
@@ -339,6 +344,7 @@ describe(AssetService.name, () => {
|
||||
isVisible: true,
|
||||
});
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.image);
|
||||
assetMock.update.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
@@ -366,7 +372,7 @@ describe(AssetService.name, () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.image);
|
||||
assetMock.update.mockResolvedValueOnce(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
|
||||
|
||||
@@ -383,15 +389,15 @@ describe(AssetService.name, () => {
|
||||
|
||||
it('should fail unlinking a live video if the asset could not be found', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
assetMock.getById.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||
expect(eventMock.emit).not.toHaveBeenCalledWith();
|
||||
expect(eventMock.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -74,29 +74,13 @@ export class AssetService extends BaseService {
|
||||
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getById(
|
||||
id,
|
||||
{
|
||||
exifInfo: true,
|
||||
sharedLinks: true,
|
||||
tags: true,
|
||||
owner: true,
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
stack: {
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
},
|
||||
files: true,
|
||||
},
|
||||
{
|
||||
faces: {
|
||||
boundingBoxX1: 'ASC',
|
||||
},
|
||||
},
|
||||
);
|
||||
const asset = await this.assetRepository.getById(id, {
|
||||
exifInfo: true,
|
||||
owner: true,
|
||||
faces: { person: true },
|
||||
stack: { assets: true },
|
||||
tags: true,
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
@@ -137,22 +121,12 @@ export class AssetService extends BaseService {
|
||||
|
||||
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
|
||||
|
||||
await this.assetRepository.update({ id, ...rest });
|
||||
const asset = await this.assetRepository.update({ id, ...rest });
|
||||
|
||||
if (previousMotion) {
|
||||
await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id });
|
||||
}
|
||||
|
||||
const asset = await this.assetRepository.getById(id, {
|
||||
exifInfo: true,
|
||||
owner: true,
|
||||
tags: true,
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
files: true,
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
@@ -202,9 +176,7 @@ export class AssetService extends BaseService {
|
||||
const { id, deleteOnDisk } = job;
|
||||
|
||||
const asset = await this.assetRepository.getById(id, {
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
faces: { person: true },
|
||||
library: true,
|
||||
stack: { assets: true },
|
||||
exifInfo: true,
|
||||
|
||||
@@ -71,10 +71,8 @@ export class BackupService extends BaseService {
|
||||
@OnJob({ name: JobName.BACKUP_DATABASE, queue: QueueName.BACKUP_DATABASE })
|
||||
async handleBackupDatabase(): Promise<JobStatus> {
|
||||
this.logger.debug(`Database Backup Started`);
|
||||
|
||||
const {
|
||||
database: { config },
|
||||
} = this.configRepository.getEnv();
|
||||
const { database } = this.configRepository.getEnv();
|
||||
const config = database.config.typeorm;
|
||||
|
||||
const isUrlConnection = config.connectionType === 'url';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import {
|
||||
DatabaseExtension,
|
||||
@@ -61,13 +62,19 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
kysely: {
|
||||
dialect: expect.any(PostgresJSDialect),
|
||||
log: ['error'],
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
},
|
||||
skipMigrations: false,
|
||||
vectorExtension: extension,
|
||||
@@ -291,13 +298,19 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
kysely: {
|
||||
dialect: expect.any(PostgresJSDialect),
|
||||
log: ['error'],
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
},
|
||||
skipMigrations: true,
|
||||
vectorExtension: DatabaseExtension.VECTORS,
|
||||
@@ -315,13 +328,19 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
kysely: {
|
||||
dialect: expect.any(PostgresJSDialect),
|
||||
log: ['error'],
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
},
|
||||
skipMigrations: true,
|
||||
vectorExtension: DatabaseExtension.VECTOR,
|
||||
|
||||
@@ -31,7 +31,12 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('getDuplicates', () => {
|
||||
it('should get duplicates', async () => {
|
||||
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe, assetStub.hasDupe]);
|
||||
assetMock.getDuplicates.mockResolvedValue([
|
||||
{
|
||||
duplicateId: assetStub.hasDupe.duplicateId!,
|
||||
assets: [assetStub.hasDupe, assetStub.hasDupe],
|
||||
},
|
||||
]);
|
||||
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
|
||||
{
|
||||
duplicateId: assetStub.hasDupe.duplicateId,
|
||||
@@ -42,12 +47,6 @@ describe(SearchService.name, () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update assets with duplicateId', async () => {
|
||||
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]);
|
||||
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([]);
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasDupe.id], { duplicateId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSearchDuplicates', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
|
||||
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
|
||||
@@ -15,25 +15,11 @@ import { usePagination } from 'src/utils/pagination';
|
||||
@Injectable()
|
||||
export class DuplicateService extends BaseService {
|
||||
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
|
||||
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
|
||||
const uniqueAssetIds: string[] = [];
|
||||
const duplicates = mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))).filter(
|
||||
(duplicate) => {
|
||||
if (duplicate.assets.length === 1) {
|
||||
uniqueAssetIds.push(duplicate.assets[0].id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
if (uniqueAssetIds.length > 0) {
|
||||
try {
|
||||
await this.assetRepository.updateAll(uniqueAssetIds, { duplicateId: null });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to remove duplicateId from assets: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
const duplicates = await this.assetRepository.getDuplicates(auth.user.id);
|
||||
return duplicates.map(({ duplicateId, assets }) => ({
|
||||
duplicateId,
|
||||
assets: assets.map((asset) => mapAsset(asset, { auth })),
|
||||
}));
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION })
|
||||
|
||||
@@ -256,8 +256,6 @@ describe(LibraryService.name, () => {
|
||||
exclusionPatterns: [],
|
||||
};
|
||||
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED);
|
||||
|
||||
expect(assetMock.remove).not.toHaveBeenCalled();
|
||||
@@ -394,7 +392,6 @@ describe(LibraryService.name, () => {
|
||||
assetPath: '/data/user1/photo.jpg',
|
||||
};
|
||||
|
||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.image);
|
||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||
|
||||
@@ -438,7 +435,6 @@ describe(LibraryService.name, () => {
|
||||
assetPath: '/data/user1/video.mp4',
|
||||
};
|
||||
|
||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.video);
|
||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||
|
||||
@@ -482,7 +478,6 @@ describe(LibraryService.name, () => {
|
||||
assetPath: '/data/user1/photo.jpg',
|
||||
};
|
||||
|
||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.image);
|
||||
libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() });
|
||||
|
||||
@@ -548,7 +543,6 @@ describe(LibraryService.name, () => {
|
||||
assetPath: '/data/user1/photo.jpg',
|
||||
};
|
||||
|
||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
|
||||
@@ -567,7 +561,6 @@ describe(LibraryService.name, () => {
|
||||
assetPath: '/data/user1/photo.jpg',
|
||||
};
|
||||
|
||||
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Stats } from 'node:fs';
|
||||
import { constants } from 'node:fs/promises';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
@@ -200,7 +201,6 @@ describe(MetadataService.name, () => {
|
||||
exifInfo: { livePhotoCID: 'CID' } as ExifEntity,
|
||||
},
|
||||
]);
|
||||
assetMock.findLivePhotoMatch.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
||||
JobStatus.SKIPPED,
|
||||
@@ -579,7 +579,6 @@ describe(MetadataService.name, () => {
|
||||
EmbeddedVideoType: 'MotionPhoto_Data',
|
||||
});
|
||||
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
|
||||
const video = randomBytes(512);
|
||||
@@ -624,7 +623,6 @@ describe(MetadataService.name, () => {
|
||||
EmbeddedVideoType: 'MotionPhoto_Data',
|
||||
});
|
||||
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
|
||||
const video = randomBytes(512);
|
||||
@@ -670,7 +668,6 @@ describe(MetadataService.name, () => {
|
||||
MicroVideoOffset: 1,
|
||||
});
|
||||
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
|
||||
const video = randomBytes(512);
|
||||
@@ -716,8 +713,9 @@ describe(MetadataService.name, () => {
|
||||
MicroVideoOffset: 1,
|
||||
});
|
||||
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue(null);
|
||||
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
|
||||
assetMock.create.mockImplementation(
|
||||
(asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise<AssetEntity>,
|
||||
);
|
||||
const video = randomBytes(512);
|
||||
storageMock.readFile.mockResolvedValue(video);
|
||||
|
||||
@@ -789,7 +787,6 @@ describe(MetadataService.name, () => {
|
||||
MicroVideoOffset: 1,
|
||||
});
|
||||
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
const video = randomBytes(512);
|
||||
storageMock.readFile.mockResolvedValue(video);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
|
||||
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
||||
import { Insertable } from 'kysely';
|
||||
import _ from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
import { constants } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Exif } from 'src/db';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum';
|
||||
import { WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
@@ -166,7 +167,7 @@ export class MetadataService extends BaseService {
|
||||
|
||||
const { width, height } = this.getImageDimensions(exifTags);
|
||||
|
||||
const exifData: Partial<ExifEntity> = {
|
||||
const exifData: Insertable<Exif> = {
|
||||
assetId: asset.id,
|
||||
|
||||
// dates
|
||||
|
||||
@@ -728,11 +728,13 @@ describe(PersonService.name, () => {
|
||||
assetId: assetStub.image.id,
|
||||
facesRecognizedAt: expect.any(Date),
|
||||
});
|
||||
expect(assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt?.getTime()).toBeGreaterThan(start);
|
||||
const facesRecognizedAt = assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date;
|
||||
expect(facesRecognizedAt.getTime()).toBeGreaterThan(start);
|
||||
});
|
||||
|
||||
it('should create a face with no person and queue recognition job', async () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock);
|
||||
searchMock.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
@@ -840,10 +842,10 @@ describe(PersonService.name, () => {
|
||||
}
|
||||
|
||||
const faces = [
|
||||
{ face: faceStub.noPerson1, distance: 0 },
|
||||
{ face: faceStub.primaryFace1, distance: 0.2 },
|
||||
{ face: faceStub.noPerson2, distance: 0.3 },
|
||||
{ face: faceStub.face1, distance: 0.4 },
|
||||
{ ...faceStub.noPerson1, distance: 0 },
|
||||
{ ...faceStub.primaryFace1, distance: 0.2 },
|
||||
{ ...faceStub.noPerson2, distance: 0.3 },
|
||||
{ ...faceStub.face1, distance: 0.4 },
|
||||
] as FaceSearchResult[];
|
||||
|
||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
@@ -867,8 +869,8 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should create a new person if the face is a core point with no person', async () => {
|
||||
const faces = [
|
||||
{ face: faceStub.noPerson1, distance: 0 },
|
||||
{ face: faceStub.noPerson2, distance: 0.3 },
|
||||
{ ...faceStub.noPerson1, distance: 0 },
|
||||
{ ...faceStub.noPerson2, distance: 0.3 },
|
||||
] as FaceSearchResult[];
|
||||
|
||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
@@ -889,7 +891,7 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should not queue face with no matches', async () => {
|
||||
const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
||||
const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
||||
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
@@ -905,8 +907,8 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should defer non-core faces to end of queue', async () => {
|
||||
const faces = [
|
||||
{ face: faceStub.noPerson1, distance: 0 },
|
||||
{ face: faceStub.noPerson2, distance: 0.4 },
|
||||
{ ...faceStub.noPerson1, distance: 0 },
|
||||
{ ...faceStub.noPerson2, distance: 0.4 },
|
||||
] as FaceSearchResult[];
|
||||
|
||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
@@ -927,8 +929,8 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should not assign person to deferred non-core face with no matching person', async () => {
|
||||
const faces = [
|
||||
{ face: faceStub.noPerson1, distance: 0 },
|
||||
{ face: faceStub.noPerson2, distance: 0.4 },
|
||||
{ ...faceStub.noPerson1, distance: 0 },
|
||||
{ ...faceStub.noPerson2, distance: 0.4 },
|
||||
] as FaceSearchResult[];
|
||||
|
||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
|
||||
@@ -261,7 +261,7 @@ export class PersonService extends BaseService {
|
||||
return force === false
|
||||
? this.assetRepository.getWithout(pagination, WithoutProperty.FACES)
|
||||
: this.assetRepository.getAll(pagination, {
|
||||
orderDirection: 'DESC',
|
||||
orderDirection: 'desc',
|
||||
withFaces: true,
|
||||
withArchived: true,
|
||||
isVisible: true,
|
||||
@@ -288,13 +288,7 @@ export class PersonService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const relations = {
|
||||
exifInfo: true,
|
||||
faces: {
|
||||
person: false,
|
||||
},
|
||||
files: true,
|
||||
};
|
||||
const relations = { exifInfo: true, faces: { person: false }, files: true };
|
||||
const [asset] = await this.assetRepository.getByIds([id], relations);
|
||||
const { previewFile } = getAssetFiles(asset.files);
|
||||
if (!asset || !previewFile) {
|
||||
@@ -491,7 +485,7 @@ export class PersonService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
let personId = matches.find((match) => match.face.personId)?.face.personId;
|
||||
let personId = matches.find((match) => match.personId)?.personId;
|
||||
if (!personId) {
|
||||
const matchWithPerson = await this.searchRepository.searchFaces({
|
||||
userIds: [face.asset.ownerId],
|
||||
@@ -502,7 +496,7 @@ export class PersonService extends BaseService {
|
||||
});
|
||||
|
||||
if (matchWithPerson.length > 0) {
|
||||
personId = matchWithPerson[0].face.personId;
|
||||
personId = matchWithPerson[0].personId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,11 @@ describe(SearchService.name, () => {
|
||||
it('should get assets by city and tag', async () => {
|
||||
assetMock.getAssetIdByCity.mockResolvedValue({
|
||||
fieldName: 'exifInfo.city',
|
||||
items: [{ value: 'Paris', data: assetStub.image.id }],
|
||||
items: [{ value: 'test-city', data: assetStub.withLocation.id }],
|
||||
});
|
||||
assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
|
||||
assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]);
|
||||
const expectedResponse = [
|
||||
{ fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] },
|
||||
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
|
||||
];
|
||||
|
||||
const result = await sut.getExploreData(authStub.user1);
|
||||
|
||||
@@ -34,16 +34,10 @@ export class SearchService extends BaseService {
|
||||
|
||||
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
|
||||
const options = { maxFields: 12, minAssetsPerField: 5 };
|
||||
const result = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
||||
const results = [result];
|
||||
const assetIds = new Set<string>(results.flatMap((field) => field.items.map((item) => item.data)));
|
||||
const assets = await this.assetRepository.getByIdsWithAllRelations([...assetIds]);
|
||||
const assetMap = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, mapAsset(asset)]));
|
||||
|
||||
return results.map(({ fieldName, items }) => ({
|
||||
fieldName,
|
||||
items: items.map(({ value, data }) => ({ value, data: assetMap.get(data) as AssetResponseDto })),
|
||||
}));
|
||||
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
||||
const assets = await this.assetRepository.getByIdsWithAllRelations(cities.items.map(({ data }) => data));
|
||||
const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
|
||||
return [{ fieldName: cities.fieldName, items }];
|
||||
}
|
||||
|
||||
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||
@@ -57,14 +51,13 @@ export class SearchService extends BaseService {
|
||||
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 250;
|
||||
const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||
{ page, size },
|
||||
{
|
||||
...dto,
|
||||
checksum,
|
||||
userIds,
|
||||
orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC',
|
||||
orderDirection: dto.order ?? AssetOrder.DESC,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -61,12 +61,15 @@ describe(TimelineService.name, () => {
|
||||
userId: authStub.admin.user.id,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
expect(assetMock.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
expect.objectContaining({
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
userIds: [authStub.admin.user.id],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include partner shared assets', async () => {
|
||||
@@ -143,11 +146,14 @@ describe(TimelineService.name, () => {
|
||||
userId: authStub.admin.user.id,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
expect(assetMock.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
expect.objectContaining({
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
userIds: [authStub.admin.user.id],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if withParners is true and isArchived true or undefined', async () => {
|
||||
|
||||
Reference in New Issue
Block a user