feat: use pgvecto.rs (#3605)
This commit is contained in:
@@ -10,19 +10,15 @@ import {
|
||||
usePagination,
|
||||
} from '@app/domain';
|
||||
import { AssetController } from '@app/immich';
|
||||
import { AssetEntity, AssetType, LibraryType, SharedLinkType } from '@app/infra/entities';
|
||||
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
|
||||
import { AssetRepository } from '@app/infra/repositories';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { api } from '@test/api';
|
||||
import { errorStub, userDto, uuidStub } from '@test/fixtures';
|
||||
import { testApp } from '@test/test-utils';
|
||||
import { generateAsset, testApp, today, yesterday } from '@test/test-utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
import request from 'supertest';
|
||||
|
||||
const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
|
||||
const yesterday = today.minus({ days: 1 });
|
||||
|
||||
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
|
||||
const dto: Record<string, any> = {
|
||||
deviceAssetId: 'example-image',
|
||||
@@ -54,30 +50,14 @@ describe(`${AssetController.name} (e2e)`, () => {
|
||||
let asset4: AssetResponseDto;
|
||||
let asset5: AssetResponseDto;
|
||||
|
||||
let assetCount = 0;
|
||||
const createAsset = async (loginResponse: LoginResponseDto, createdAt: Date, other: Partial<AssetEntity> = {}) => {
|
||||
const id = assetCount++;
|
||||
const asset = await assetRepository.create({
|
||||
createdAt: today.toJSDate(),
|
||||
updatedAt: today.toJSDate(),
|
||||
ownerId: loginResponse.userId,
|
||||
checksum: randomBytes(20),
|
||||
originalPath: `/tests/test_${id}`,
|
||||
deviceAssetId: `test_${id}`,
|
||||
deviceId: 'e2e-test',
|
||||
libraryId: (
|
||||
libraries.find(
|
||||
({ ownerId, type }) => ownerId === loginResponse.userId && type === LibraryType.UPLOAD,
|
||||
) as LibraryResponseDto
|
||||
).id,
|
||||
isVisible: true,
|
||||
fileCreatedAt: createdAt,
|
||||
fileModifiedAt: new Date(),
|
||||
localDateTime: createdAt,
|
||||
type: AssetType.IMAGE,
|
||||
originalFileName: `test_${id}`,
|
||||
...other,
|
||||
});
|
||||
const createAsset = async (
|
||||
loginResponse: LoginResponseDto,
|
||||
fileCreatedAt: Date,
|
||||
other: Partial<AssetEntity> = {},
|
||||
) => {
|
||||
const asset = await assetRepository.create(
|
||||
generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }),
|
||||
);
|
||||
|
||||
return mapAsset(asset);
|
||||
};
|
||||
@@ -764,7 +744,11 @@ describe(`${AssetController.name} (e2e)`, () => {
|
||||
const personRepository = app.get<IPersonRepository>(IPersonRepository);
|
||||
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
|
||||
|
||||
await personRepository.createFace({ assetId: asset1.id, personId: person.id });
|
||||
await personRepository.createFace({
|
||||
assetId: asset1.id,
|
||||
personId: person.id,
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
});
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.put(`/asset/${asset1.id}`)
|
||||
@@ -1339,7 +1323,11 @@ describe(`${AssetController.name} (e2e)`, () => {
|
||||
beforeEach(async () => {
|
||||
const personRepository = app.get<IPersonRepository>(IPersonRepository);
|
||||
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
|
||||
await personRepository.createFace({ assetId: asset1.id, personId: person.id });
|
||||
await personRepository.createFace({
|
||||
assetId: asset1.id,
|
||||
personId: person.id,
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return asset with facesRecognizedAt unset', async () => {
|
||||
|
||||
@@ -37,7 +37,11 @@ describe(`${PersonController.name}`, () => {
|
||||
name: 'visible_person',
|
||||
thumbnailPath: '/thumbnail/face_asset',
|
||||
});
|
||||
await personRepository.createFace({ assetId: faceAsset.id, personId: visiblePerson.id });
|
||||
await personRepository.createFace({
|
||||
assetId: faceAsset.id,
|
||||
personId: visiblePerson.id,
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
});
|
||||
|
||||
hiddenPerson = await personRepository.create({
|
||||
ownerId: loginResponse.userId,
|
||||
@@ -45,7 +49,11 @@ describe(`${PersonController.name}`, () => {
|
||||
isHidden: true,
|
||||
thumbnailPath: '/thumbnail/face_asset',
|
||||
});
|
||||
await personRepository.createFace({ assetId: faceAsset.id, personId: hiddenPerson.id });
|
||||
await personRepository.createFace({
|
||||
assetId: faceAsset.id,
|
||||
personId: hiddenPerson.id,
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /person', () => {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
AssetResponseDto,
|
||||
IAssetRepository,
|
||||
ISmartInfoRepository,
|
||||
LibraryResponseDto,
|
||||
LoginResponseDto,
|
||||
mapAsset,
|
||||
} from '@app/domain';
|
||||
import { SearchController } from '@app/immich';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { api } from '@test/api';
|
||||
import { errorStub } from '@test/fixtures';
|
||||
import { generateAsset, testApp } from '@test/test-utils';
|
||||
import request from 'supertest';
|
||||
|
||||
describe(`${SearchController.name}`, () => {
|
||||
let app: INestApplication;
|
||||
let server: any;
|
||||
let loginResponse: LoginResponseDto;
|
||||
let accessToken: string;
|
||||
let libraries: LibraryResponseDto[];
|
||||
let assetRepository: IAssetRepository;
|
||||
let smartInfoRepository: ISmartInfoRepository;
|
||||
let asset1: AssetResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server, app] = await testApp.create();
|
||||
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||
smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
loginResponse = await api.authApi.adminLogin(server);
|
||||
accessToken = loginResponse.accessToken;
|
||||
libraries = await api.libraryApi.getAll(server, accessToken);
|
||||
|
||||
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
|
||||
await assetRepository.upsertExif({
|
||||
assetId,
|
||||
latitude: 90,
|
||||
longitude: 90,
|
||||
city: 'Immich',
|
||||
state: 'Nebraska',
|
||||
country: 'United States',
|
||||
make: 'Canon',
|
||||
model: 'EOS Rebel T7',
|
||||
lensModel: 'Fancy lens',
|
||||
});
|
||||
await smartInfoRepository.upsert(
|
||||
{ assetId, objects: ['car', 'tree'], tags: ['accident'] },
|
||||
Array.from({ length: 512 }, Math.random),
|
||||
);
|
||||
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true });
|
||||
if (!assetWithMetadata) {
|
||||
throw new Error('Asset not found');
|
||||
}
|
||||
asset1 = mapAsset(assetWithMetadata);
|
||||
});
|
||||
|
||||
describe('GET /search', () => {
|
||||
beforeEach(async () => {});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get('/search');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should return assets when searching by exif', async () => {
|
||||
if (!asset1?.exifInfo?.make) {
|
||||
throw new Error('Asset 1 does not have exif info');
|
||||
}
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get('/search')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query({ q: asset1.exifInfo.make });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
albums: {
|
||||
total: 0,
|
||||
count: 0,
|
||||
items: [],
|
||||
facets: [],
|
||||
},
|
||||
assets: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
items: [
|
||||
{
|
||||
id: asset1.id,
|
||||
exifInfo: {
|
||||
make: asset1.exifInfo.make,
|
||||
},
|
||||
},
|
||||
],
|
||||
facets: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be case-insensitive for metadata search', async () => {
|
||||
if (!asset1?.exifInfo?.make) {
|
||||
throw new Error('Asset 1 does not have exif info');
|
||||
}
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get('/search')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query({ q: asset1.exifInfo.make.toLowerCase() });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
albums: {
|
||||
total: 0,
|
||||
count: 0,
|
||||
items: [],
|
||||
facets: [],
|
||||
},
|
||||
assets: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
items: [
|
||||
{
|
||||
id: asset1.id,
|
||||
exifInfo: {
|
||||
make: asset1.exifInfo.make,
|
||||
},
|
||||
},
|
||||
],
|
||||
facets: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be whitespace-insensitive for metadata search', async () => {
|
||||
if (!asset1?.exifInfo?.make) {
|
||||
throw new Error('Asset 1 does not have exif info');
|
||||
}
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get('/search')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query({ q: ` ${asset1.exifInfo.make} ` });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
albums: {
|
||||
total: 0,
|
||||
count: 0,
|
||||
items: [],
|
||||
facets: [],
|
||||
},
|
||||
assets: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
items: [
|
||||
{
|
||||
id: asset1.id,
|
||||
exifInfo: {
|
||||
make: asset1.exifInfo.make,
|
||||
},
|
||||
},
|
||||
],
|
||||
facets: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return assets when searching by object', async () => {
|
||||
if (!asset1?.smartInfo?.objects) {
|
||||
throw new Error('Asset 1 does not have smart info');
|
||||
}
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get('/search')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query({ q: asset1.smartInfo.objects[0] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
albums: {
|
||||
total: 0,
|
||||
count: 0,
|
||||
items: [],
|
||||
facets: [],
|
||||
},
|
||||
assets: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
items: [
|
||||
{
|
||||
id: asset1.id,
|
||||
smartInfo: {
|
||||
objects: asset1.smartInfo.objects,
|
||||
tags: asset1.smartInfo.tags,
|
||||
},
|
||||
},
|
||||
],
|
||||
facets: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
|
||||
oauth: false,
|
||||
oauthAutoLaunch: false,
|
||||
passwordLogin: true,
|
||||
search: false,
|
||||
search: true,
|
||||
sidecar: true,
|
||||
tagImage: false,
|
||||
trash: true,
|
||||
|
||||
@@ -35,7 +35,7 @@ export default async () => {
|
||||
|
||||
if (process.env.DB_HOSTNAME === undefined) {
|
||||
// DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
|
||||
const pg = await new PostgreSqlContainer('postgres')
|
||||
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.11')
|
||||
.withExposedPorts(5432)
|
||||
.withDatabase('immich')
|
||||
.withUsername('postgres')
|
||||
@@ -47,7 +47,6 @@ export default async () => {
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.TYPESENSE_ENABLED = 'false';
|
||||
process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false';
|
||||
process.env.IMMICH_TEST_ENV = 'true';
|
||||
process.env.TZ = 'Z';
|
||||
|
||||
Reference in New Issue
Block a user