Merge branch 'main' of https://github.com/immich-app/immich into feat/crawl-wrapper

This commit is contained in:
Jonathan Jogenfors
2026-02-20 13:18:27 +01:00
154 changed files with 5268 additions and 1690 deletions
+6 -6
View File
@@ -18,14 +18,14 @@ export class AssetFaceFactory {
static from(dto: AssetFaceLike = {}) {
return new AssetFaceFactory({
assetId: newUuid(),
boundingBoxX1: 11,
boundingBoxX2: 12,
boundingBoxY1: 21,
boundingBoxY2: 22,
boundingBoxX1: 100,
boundingBoxX2: 200,
boundingBoxY1: 100,
boundingBoxY2: 200,
deletedAt: null,
id: newUuid(),
imageHeight: 42,
imageWidth: 420,
imageHeight: 500,
imageWidth: 400,
isVisible: true,
personId: null,
sourceType: SourceType.MachineLearning,
+1 -1
View File
@@ -96,7 +96,7 @@ export class AssetFactory {
}
face(dto: AssetFaceLike = {}, builder?: FactoryBuilder<AssetFaceFactory>) {
this.#faces.push(build(AssetFaceFactory.from(dto), builder));
this.#faces.push(build(AssetFaceFactory.from({ assetId: this.value?.id, ...dto }), builder));
return this;
}
-160
View File
@@ -1,160 +0,0 @@
import { SourceType } from 'src/enum';
import { AssetFactory } from 'test/factories/asset.factory';
import { personStub } from 'test/fixtures/person.stub';
export const faceStub = {
face1: Object.freeze({
id: 'assetFaceId1',
assetId: 'asset-id',
asset: {
...AssetFactory.create({ id: 'asset-id' }),
libraryId: null,
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
stackId: null,
},
personId: personStub.withName.id,
person: personStub.withName,
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MachineLearning,
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
deletedAt: new Date(),
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
primaryFace1: Object.freeze({
id: 'assetFaceId2',
assetId: 'asset-id',
asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.primaryPerson.id,
person: personStub.primaryPerson,
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MachineLearning,
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
mergeFace1: Object.freeze({
id: 'assetFaceId3',
assetId: 'asset-id',
asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.mergePerson.id,
person: personStub.mergePerson,
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MachineLearning,
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
noPerson1: Object.freeze({
id: 'assetFaceId8',
assetId: 'asset-id',
asset: AssetFactory.create({ id: 'asset-id' }),
personId: null,
person: null,
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MachineLearning,
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
noPerson2: Object.freeze({
id: 'assetFaceId9',
assetId: 'asset-id',
asset: AssetFactory.create({ id: 'asset-id' }),
personId: null,
person: null,
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MachineLearning,
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
fromExif1: Object.freeze({
id: 'assetFaceId9',
assetId: 'asset-id',
asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.randomPerson.id,
person: personStub.randomPerson,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
sourceType: SourceType.Exif,
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
fromExif2: Object.freeze({
id: 'assetFaceId9',
assetId: 'asset-id',
asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.randomPerson.id,
person: personStub.randomPerson,
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.Exif,
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
withBirthDate: Object.freeze({
id: 'assetFaceId10',
assetId: 'asset-id',
asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.withBirthDate.id,
person: personStub.withBirthDate,
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MachineLearning,
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
};
+29
View File
@@ -1,3 +1,6 @@
import { Selectable } from 'kysely';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
@@ -20,3 +23,29 @@ export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>)
isEdited: asset.isEdited,
};
};
export const getAsDetectedFace = (face: ReturnType<AssetFaceFactory['build']>) => ({
faces: [
{
boundingBox: {
x1: face.boundingBoxX1,
y1: face.boundingBoxY1,
x2: face.boundingBoxX2,
y2: face.boundingBoxY2,
},
embedding: '[1, 2, 3, 4]',
score: 0.2,
},
],
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
});
export const getForFacialRecognitionJob = (
face: ReturnType<AssetFaceFactory['build']>,
asset: Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null,
) => ({
...face,
asset,
faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' },
});
@@ -1,12 +1,15 @@
import { Kysely } from 'kysely';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFileType, AssetMetadataKey, AssetStatus, JobName, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
@@ -25,6 +28,7 @@ const setup = (db?: Kysely<DB>) => {
database: db || defaultDatabase,
real: [
AssetRepository,
AssetEditRepository,
AssetJobRepository,
AlbumRepository,
AccessRepository,
@@ -32,7 +36,7 @@ const setup = (db?: Kysely<DB>) => {
StackRepository,
UserRepository,
],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository, OcrRepository],
});
};
@@ -586,6 +590,108 @@ describe(AssetService.name, () => {
});
});
describe('getOcr', () => {
it('should require access', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access');
});
it('should work', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }),
]);
});
it('should apply rotation', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
await ctx.database
.insertInto('asset_edit')
.values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 })
.execute();
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
expect.objectContaining({
x1: 0.6,
x2: 0.8,
x3: 0.8,
x4: 0.6,
y1: expect.any(Number),
y2: expect.any(Number),
y3: 0.3,
y4: 0.3,
}),
]);
});
});
describe('getOcr', () => {
it('should require access', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access');
});
it('should work', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }),
]);
});
it('should apply rotation', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
await ctx.database
.insertInto('asset_edit')
.values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 })
.execute();
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
expect.objectContaining({
x1: 0.6,
x2: 0.8,
x3: 0.8,
x4: 0.6,
y1: expect.any(Number),
y2: expect.any(Number),
y3: 0.3,
y4: 0.3,
}),
]);
});
});
describe('upsertBulkMetadata', () => {
it('should work', async () => {
const { sut, ctx } = setup();
@@ -758,4 +864,38 @@ describe(AssetService.name, () => {
expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]);
});
});
describe('editAsset', () => {
it('should require access', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await expect(
sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }),
).rejects.toThrow('Not found or no asset.edit.create access');
});
it('should work', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
const editAction = { action: AssetEditAction.Rotate, parameters: { angle: 90 } } as const;
await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({
assetId: asset.id,
edits: [editAction],
});
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual(
expect.objectContaining({ isEdited: true }),
);
await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editAction]);
});
});
});
@@ -51,7 +51,13 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
deleteMetadataByKey: vitest.fn(),
deleteBulkMetadata: vitest.fn(),
getForOriginal: vitest.fn(),
getForOriginals: vitest.fn(),
getForThumbnail: vitest.fn(),
getForVideo: vitest.fn(),
getForEdit: vitest.fn(),
getForOcr: vitest.fn(),
getForMetadataExtractionTags: vitest.fn(),
getForFaces: vitest.fn(),
getForUpdateTags: vitest.fn(),
};
};