Merge branch 'main' of https://github.com/immich-app/immich into chore/admin-only-library

This commit is contained in:
Jonathan Jogenfors
2024-02-27 20:29:40 +01:00
229 changed files with 5424 additions and 3368 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev
FROM ghcr.io/immich-app/base-server-dev:20240227@sha256:b1e212c106ce2318a587e0b2ef377215c958e877f61993ed9310534e4589cce4 as dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -40,7 +40,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c
FROM ghcr.io/immich-app/base-server-prod:20240227@sha256:d47f5f7f2b6c53957c6353352b2fa24f2845da50e6491a7c74eb779ace10628c
WORKDIR /usr/src/app
ENV NODE_ENV=production \
-364
View File
@@ -538,90 +538,6 @@ describe(`${AssetController.name} (e2e)`, () => {
}
});
describe('GET /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/asset/${uuidStub.notFound}`);
expect(body).toEqual(errorStub.unauthorized);
expect(status).toBe(401);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.get(`/asset/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(server)
.get(`/asset/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should get the asset info', async () => {
const { status, body } = await request(server)
.get(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: asset1.id });
});
it('should work with a shared link', async () => {
const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
});
const { status, body } = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: asset1.id });
});
it('should not send people data for shared links for un-authenticated users', async () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
await personRepository.createFaces([
{
assetId: asset1.id,
personId: person.id,
embedding: Array.from({ length: 512 }, Math.random),
},
]);
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(status).toEqual(200);
expect(body).toMatchObject({
id: asset1.id,
isFavorite: true,
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '',
},
],
});
const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
});
const data = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`);
expect(data.status).toBe(200);
expect(data.body).toMatchObject({ people: [] });
});
});
describe('POST /asset/upload', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
@@ -759,286 +675,6 @@ describe(`${AssetController.name} (e2e)`, () => {
});
});
describe('PUT /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.put(`/asset/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should favorite an asset', async () => {
expect(asset1).toMatchObject({ isFavorite: false });
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(body).toMatchObject({ id: asset1.id, isFavorite: true });
expect(status).toEqual(200);
});
it('should archive an asset', async () => {
expect(asset1).toMatchObject({ isArchived: false });
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true });
expect(body).toMatchObject({ id: asset1.id, isArchived: true });
expect(status).toEqual(200);
});
it('should update date time original', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
expect(body).toMatchObject({
id: asset1.id,
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z' }),
});
expect(status).toEqual(200);
});
it('should reject invalid gps coordinates', async () => {
for (const test of [
{ latitude: 12 },
{ longitude: 12 },
{ latitude: 12, longitude: 'abc' },
{ latitude: 'abc', longitude: 12 },
{ latitude: null, longitude: 12 },
{ latitude: 12, longitude: null },
{ latitude: 91, longitude: 12 },
{ latitude: -91, longitude: 12 },
{ latitude: 12, longitude: -181 },
{ latitude: 12, longitude: 181 },
]) {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.send(test)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
}
});
it('should update gps data', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ latitude: 12, longitude: 12 });
expect(body).toMatchObject({
id: asset1.id,
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
});
expect(status).toEqual(200);
});
it('should set the description', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'Test asset description' });
expect(body).toMatchObject({
id: asset1.id,
exifInfo: expect.objectContaining({ description: 'Test asset description' }),
});
expect(status).toEqual(200);
});
it('should return tagged people', async () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
await personRepository.createFaces([
{
assetId: asset1.id,
personId: person.id,
embedding: Array.from({ length: 512 }, Math.random),
},
]);
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(status).toEqual(200);
expect(body).toMatchObject({
id: asset1.id,
isFavorite: true,
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '',
},
],
});
});
});
describe('GET /asset/statistics', () => {
beforeEach(async () => {
await api.assetApi.upload(server, user1.accessToken, 'favored_asset', { isFavorite: true });
await api.assetApi.upload(server, user1.accessToken, 'archived_asset', { isArchived: true });
await api.assetApi.upload(server, user1.accessToken, 'favored_archived_asset', {
isFavorite: true,
isArchived: true,
});
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return stats of all assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(body).toEqual({ images: 6, videos: 1, total: 7 });
expect(status).toBe(200);
});
it('should return stats of all favored assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isFavorite: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 2, videos: 1, total: 3 });
});
it('should return stats of all archived assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isArchived: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 3, videos: 0, total: 3 });
});
it('should return stats of all favored and archived assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isFavorite: true, isArchived: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 1, videos: 0, total: 1 });
});
it('should return stats of all assets neither favored nor archived', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isFavorite: false, isArchived: false });
expect(status).toBe(200);
expect(body).toEqual({ images: 2, videos: 0, total: 2 });
});
});
describe('GET /asset/random', () => {
beforeAll(async () => {
await Promise.all([
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
]);
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/random');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it.each(Array(10))('should return 1 random assets', async () => {
const { status, body } = await request(server)
.get('/asset/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
//
// assets owned by user2
expect(assets[0].id).not.toBe(asset4.id);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id);
});
it.each(Array(10))('should return 2 random assets', async () => {
const { status, body } = await request(server)
.get('/asset/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(2);
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id);
// assets owned by user2
expect(asset.id).not.toBe(asset4.id);
}
});
it.each(Array(10))(
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
async () => {
const { status, body } = await request(server)
.get('/[]asset/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset4.id })]);
},
);
it('should return error', async () => {
const { status } = await request(server)
.get('/asset/random?count=ABC')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
});
});
describe('GET /asset/time-buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH });
+1 -5
View File
@@ -1,4 +1,4 @@
import { AssetBulkDeleteDto, AssetResponseDto } from '@app/domain';
import { AssetResponseDto } from '@app/domain';
import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { randomBytes } from 'node:crypto';
@@ -74,8 +74,4 @@ export const assetApi = {
expect(status).toBe(200);
return body;
},
delete: async (server: any, accessToken: string, dto: AssetBulkDeleteDto) => {
const { status } = await request(server).delete('/asset').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(status).toBe(204);
},
};
-80
View File
@@ -1,80 +0,0 @@
import { LoginResponseDto } from '@app/domain';
import { api } from 'e2e/client';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { App } from 'supertest/types';
import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils';
const assetFilePath = join(IMMICH_TEST_ASSET_PATH, 'formats/png/density_plot.png');
describe(`Trash (e2e)`, () => {
let server: App;
let admin: LoginResponseDto;
beforeAll(async () => {
const app = await testApp.create();
server = app.getHttpServer();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
});
afterAll(async () => {
await testApp.teardown();
});
it('should move an asset to trash', async () => {
const content = await readFile(assetFilePath);
const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
content,
filename: basename(assetFilePath),
});
const uploadedAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(uploadedAsset.isTrashed).toBe(false);
await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] });
const deletedAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(deletedAsset.isTrashed).toBe(true);
});
it('should delete all trashed assets', async () => {
const content = await readFile(assetFilePath);
const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
content,
filename: basename(assetFilePath),
});
await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] });
const assetsBeforeEmpty = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assetsBeforeEmpty.length).toBe(1);
await api.trashApi.empty(server, admin.accessToken);
const assetsAfterEmpty = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assetsAfterEmpty.length).toBe(0);
});
it('should restore all trashed assets', async () => {
const content = await readFile(assetFilePath);
const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
content,
filename: basename(assetFilePath),
});
await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] });
const deletedAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(deletedAsset.isTrashed).toBe(true);
await api.trashApi.restore(server, admin.accessToken);
const restoredAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(restoredAsset.isTrashed).toBe(false);
});
});
+108 -108
View File
@@ -3179,9 +3179,9 @@
}
},
"node_modules/@types/node": {
"version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -3281,9 +3281,9 @@
}
},
"node_modules/@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/send": {
@@ -3398,16 +3398,16 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.2.tgz",
"integrity": "sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/type-utils": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/type-utils": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -3433,15 +3433,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.2.tgz",
"integrity": "sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4"
},
"engines": {
@@ -3461,13 +3461,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.2.tgz",
"integrity": "sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1"
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -3478,13 +3478,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.2.tgz",
"integrity": "sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@@ -3505,9 +3505,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.2.tgz",
"integrity": "sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -3518,13 +3518,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz",
"integrity": "sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -3570,17 +3570,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.2.tgz",
"integrity": "sha512-PZPIONBIB/X684bhT1XlrkjNZJIEevwkKDsdwfiu1WeqBxYEEdIgVDgm8/bbKHVu+6YOpeRqcfImTdImx/4Bsw==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"semver": "^7.5.4"
},
"engines": {
@@ -3595,12 +3595,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz",
"integrity": "sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@@ -5504,9 +5504,9 @@
}
},
"node_modules/dotenv": {
"version": "16.4.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.4.tgz",
"integrity": "sha512-XvPXc8XAQThSjAbY6cQ/9PcBXmFoWuw1sQ3b8HqUCR6ziGXjkTi//kB9SWa2UwqlgdAIuRqAa/9hVljzPehbYg==",
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"engines": {
"node": ">=12"
},
@@ -8139,9 +8139,9 @@
}
},
"node_modules/joi": {
"version": "17.12.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.1.tgz",
"integrity": "sha512-vtxmq+Lsc5SlfqotnfVjlViWfOL9nt/avKNbKYizwf6gsCfq9NYY/ceYRMFD8XDdrjJ9abJyScWmhmIiy+XRtQ==",
"version": "17.12.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.2.tgz",
"integrity": "sha512-RonXAIzCiHLc8ss3Ibuz45u28GOsWE1UpfDXLbN/9NKbL4tCJf8TWYVKsoYuuh+sAUt7fsSNpA+r2+TBA6Wjmw==",
"dependencies": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",
@@ -14730,9 +14730,9 @@
}
},
"@types/node": {
"version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"requires": {
"undici-types": "~5.26.4"
}
@@ -14819,9 +14819,9 @@
}
},
"@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"@types/send": {
@@ -14936,16 +14936,16 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.2.tgz",
"integrity": "sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/type-utils": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/type-utils": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -14955,54 +14955,54 @@
}
},
"@typescript-eslint/parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.2.tgz",
"integrity": "sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4"
}
},
"@typescript-eslint/scope-manager": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.2.tgz",
"integrity": "sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1"
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2"
}
},
"@typescript-eslint/type-utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.2.tgz",
"integrity": "sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==",
"dev": true,
"requires": {
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
}
},
"@typescript-eslint/types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.2.tgz",
"integrity": "sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz",
"integrity": "sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -15032,27 +15032,27 @@
}
},
"@typescript-eslint/utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.2.tgz",
"integrity": "sha512-PZPIONBIB/X684bhT1XlrkjNZJIEevwkKDsdwfiu1WeqBxYEEdIgVDgm8/bbKHVu+6YOpeRqcfImTdImx/4Bsw==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"semver": "^7.5.4"
}
},
"@typescript-eslint/visitor-keys": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz",
"integrity": "sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"eslint-visitor-keys": "^3.4.1"
}
},
@@ -16494,9 +16494,9 @@
}
},
"dotenv": {
"version": "16.4.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.4.tgz",
"integrity": "sha512-XvPXc8XAQThSjAbY6cQ/9PcBXmFoWuw1sQ3b8HqUCR6ziGXjkTi//kB9SWa2UwqlgdAIuRqAa/9hVljzPehbYg=="
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg=="
},
"dotenv-expand": {
"version": "10.0.0",
@@ -18453,9 +18453,9 @@
}
},
"joi": {
"version": "17.12.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.1.tgz",
"integrity": "sha512-vtxmq+Lsc5SlfqotnfVjlViWfOL9nt/avKNbKYizwf6gsCfq9NYY/ceYRMFD8XDdrjJ9abJyScWmhmIiy+XRtQ==",
"version": "17.12.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.2.tgz",
"integrity": "sha512-RonXAIzCiHLc8ss3Ibuz45u28GOsWE1UpfDXLbN/9NKbL4tCJf8TWYVKsoYuuh+sAUt7fsSNpA+r2+TBA6Wjmw==",
"requires": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",
+1 -1
View File
@@ -91,7 +91,7 @@ export const citiesFile = 'cities500.txt';
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.txt');
export const geodataCitites500Path = join(GEODATA_ROOT_PATH, citiesFile);
export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
const image: Record<string, string[]> = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
+5 -14
View File
@@ -1801,7 +1801,7 @@ describe(MediaService.name, () => {
{
inputOptions: [],
outputOptions: [
`-c:v hevc_rkmpp_encoder`,
`-c:v hevc_rkmpp`,
'-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
@@ -1810,17 +1810,12 @@ describe(MediaService.name, () => {
'-g 256',
'-tag:v hvc1',
'-v verbose',
'-vf scale=-2:720,format=yuv420p',
'-level 153',
'-rc_mode 3',
'-quality_min 0',
'-quality_max 100',
'-b:v 10000k',
'-width 1280',
'-height 720',
],
twoPass: false,
ffmpegPath: 'ffmpeg_mpp',
ldLibraryPath: '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp',
},
);
});
@@ -1841,7 +1836,7 @@ describe(MediaService.name, () => {
{
inputOptions: [],
outputOptions: [
`-c:v h264_rkmpp_encoder`,
`-c:v h264_rkmpp`,
'-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
@@ -1849,16 +1844,12 @@ describe(MediaService.name, () => {
'-map 0:1',
'-g 256',
'-v verbose',
'-vf scale=-2:720,format=yuv420p',
'-level 51',
'-rc_mode 2',
'-quality_min 51',
'-quality_max 51',
'-width 1280',
'-height 720',
'-qp_init 30',
],
twoPass: false,
ffmpegPath: 'ffmpeg_mpp',
ldLibraryPath: '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp',
},
);
});
+5 -28
View File
@@ -607,16 +607,6 @@ export class VAAPIConfig extends BaseHWConfig {
}
export class RKMPPConfig extends BaseHWConfig {
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo): TranscodeOptions {
const options = super.getOptions(target, videoStream, audioStream);
options.ffmpegPath = 'ffmpeg_mpp';
options.ldLibraryPath = '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp';
if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) {
options.outputOptions.push(...this.getSizeOptions(videoStream));
}
return options;
}
eligibleForTwoPass(): boolean {
return false;
}
@@ -628,18 +618,6 @@ export class RKMPPConfig extends BaseHWConfig {
return [];
}
getFilterOptions(videoStream: VideoStreamInfo) {
return this.shouldToneMap(videoStream) ? this.getToneMapping() : [];
}
getSizeOptions(videoStream: VideoStreamInfo) {
if (this.shouldScale(videoStream)) {
const { width, height } = this.getSize(videoStream);
return [`-width ${width}`, `-height ${height}`];
}
return [];
}
getPresetOptions() {
switch (this.config.targetVideoCodec) {
case VideoCodec.H264: {
@@ -659,12 +637,11 @@ export class RKMPPConfig extends BaseHWConfig {
getBitrateOptions() {
const bitrate = this.getMaxBitrateValue();
if (bitrate > 0) {
return ['-rc_mode 3', '-quality_min 0', '-quality_max 100', `-b:v ${bitrate}${this.getBitrateUnit()}`];
} else {
// convert CQP from 51-10 to 0-100, values below 10 are set to 10
const quality = Math.floor(125 - Math.max(this.config.crf, 10) * (125 / 51));
return ['-rc_mode 2', `-quality_min ${quality}`, `-quality_max ${quality}`];
// -b:v specifies max bitrate, average bitrate is derived automatically...
return ['-rc_mode 3', `-b:v ${bitrate}${this.getBitrateUnit()}`];
}
// use CRF value as QP value
return ['-rc_mode 2', `-qp_init ${this.config.crf}`];
}
getSupportedCodecs() {
@@ -672,6 +649,6 @@ export class RKMPPConfig extends BaseHWConfig {
}
getVideoCodec(): string {
return `${this.config.targetVideoCodec}_rkmpp_encoder`;
return `${this.config.targetVideoCodec}_rkmpp`;
}
}
@@ -51,8 +51,6 @@ export interface TranscodeOptions {
inputOptions: string[];
outputOptions: string[];
twoPass: boolean;
ffmpegPath?: string;
ldLibraryPath?: string;
}
export interface BitrateDistribution {
@@ -1,4 +1,4 @@
import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities';
import { AssetEntity, AssetFaceEntity, AssetType, GeodataPlacesEntity, SmartInfoEntity } from '@app/infra/entities';
import { Paginated } from '../domain.util';
export const ISearchRepository = 'ISearchRepository';
@@ -186,4 +186,6 @@ export interface ISearchRepository {
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
deleteAllSearchEmbeddings(): Promise<void>;
}
+25 -1
View File
@@ -1,5 +1,5 @@
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
import { AssetType } from '@app/infra/entities';
import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
@@ -241,6 +241,12 @@ export class SearchDto {
size?: number;
}
export class SearchPlacesDto {
@IsString()
@IsNotEmpty()
name!: string;
}
export class SearchPeopleDto {
@IsString()
@IsNotEmpty()
@@ -251,3 +257,21 @@ export class SearchPeopleDto {
@Optional()
withHidden?: boolean;
}
export class PlacesResponseDto {
name!: string;
latitude!: number;
longitude!: number;
admin1name?: string;
admin2name?: string;
}
export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto {
return {
name: place.name,
latitude: place.latitude,
longitude: place.longitude,
admin1name: place.admin1Name,
admin2name: place.admin2Name,
};
}
+30 -21
View File
@@ -16,7 +16,15 @@ import {
SearchStrategy,
} from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config';
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
import {
MetadataSearchDto,
PlacesResponseDto,
SearchDto,
SearchPeopleDto,
SearchPlacesDto,
SmartSearchDto,
mapPlaces,
} from './dto';
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
import { SearchResponseDto } from './response-dto';
@@ -41,6 +49,11 @@ export class SearchService {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
}
async searchPlaces(dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
const places = await this.searchRepository.searchPlaces(dto.name);
return places.map((place) => mapPlaces(place));
}
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
await this.configCore.requireFeature(FeatureFlag.SEARCH);
const options = { maxFields: 12, minAssetsPerField: 5 };
@@ -182,26 +195,22 @@ export class SearchService {
}
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
if (dto.type === SearchSuggestionType.COUNTRY) {
return this.metadataRepository.getCountries(auth.user.id);
switch (dto.type) {
case SearchSuggestionType.COUNTRY: {
return this.metadataRepository.getCountries(auth.user.id);
}
case SearchSuggestionType.STATE: {
return this.metadataRepository.getStates(auth.user.id, dto.country);
}
case SearchSuggestionType.CITY: {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
}
case SearchSuggestionType.CAMERA_MAKE: {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
}
case SearchSuggestionType.CAMERA_MODEL: {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
}
}
if (dto.type === SearchSuggestionType.STATE) {
return this.metadataRepository.getStates(auth.user.id, dto.country);
}
if (dto.type === SearchSuggestionType.CITY) {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
}
if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
}
if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
}
return [];
}
}
@@ -71,6 +71,7 @@ describe(SmartInfoService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]);
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH);
expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
});
it('should queue all the assets', async () => {
@@ -83,6 +84,7 @@ describe(SmartInfoService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]);
expect(assetMock.getAll).toHaveBeenCalled();
expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled();
});
});
@@ -50,6 +50,10 @@ export class SmartInfoService {
return true;
}
if (force) {
await this.repository.deleteAllSearchEmbeddings();
}
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
@@ -117,7 +117,7 @@ export class StorageTemplateService {
return true;
}
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination),
this.assetRepository.getAll(pagination, { withExif: true }),
);
const users = await this.userRepository.getList();
@@ -2,9 +2,11 @@ import {
AuthDto,
MetadataSearchDto,
PersonResponseDto,
PlacesResponseDto,
SearchDto,
SearchExploreResponseDto,
SearchPeopleDto,
SearchPlacesDto,
SearchResponseDto,
SearchService,
SmartSearchDto,
@@ -48,6 +50,11 @@ export class SearchController {
return this.service.searchPerson(auth, dto);
}
@Get('places')
searchPlaces(@Query() dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
return this.service.searchPlaces(dto);
}
@Get('suggestions')
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
return this.service.getSearchSuggestions(auth, dto);
@@ -1,10 +0,0 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_admin1')
export class GeodataAdmin1Entity {
@PrimaryColumn({ type: 'varchar' })
key!: string;
@Column({ type: 'varchar' })
name!: string;
}
@@ -1,10 +0,0 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_admin2')
export class GeodataAdmin2Entity {
@PrimaryColumn({ type: 'varchar' })
key!: string;
@Column({ type: 'varchar' })
name!: string;
}
@@ -1,6 +1,4 @@
import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity';
import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_places', { synchronize: false })
export class GeodataPlacesEntity {
@@ -21,7 +19,7 @@ export class GeodataPlacesEntity {
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
// type: 'earth',
// })
earthCoord!: unknown;
// earthCoord!: unknown;
@Column({ type: 'char', length: 2 })
countryCode!: string;
@@ -32,27 +30,14 @@ export class GeodataPlacesEntity {
@Column({ type: 'varchar', length: 80, nullable: true })
admin2Code!: string;
@Column({
type: 'varchar',
generatedType: 'STORED',
asExpression: `"countryCode" || '.' || "admin1Code"`,
nullable: true,
})
admin1Key!: string;
@Column({ type: 'varchar', nullable: true })
admin1Name!: string;
@ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
admin1!: GeodataAdmin1Entity;
@Column({ type: 'varchar', nullable: true })
admin2Name!: string;
@Column({
type: 'varchar',
generatedType: 'STORED',
asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`,
nullable: true,
})
admin2Key!: string;
@ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
admin2!: GeodataAdmin2Entity;
@Column({ type: 'varchar', nullable: true })
alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
-6
View File
@@ -7,8 +7,6 @@ import { AssetStackEntity } from './asset-stack.entity';
import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity';
import { GeodataAdmin1Entity } from './geodata-admin1.entity';
import { GeodataAdmin2Entity } from './geodata-admin2.entity';
import { GeodataPlacesEntity } from './geodata-places.entity';
import { LibraryEntity } from './library.entity';
import { MoveEntity } from './move.entity';
@@ -32,8 +30,6 @@ export * from './asset-stack.entity';
export * from './asset.entity';
export * from './audit.entity';
export * from './exif.entity';
export * from './geodata-admin1.entity';
export * from './geodata-admin2.entity';
export * from './geodata-places.entity';
export * from './library.entity';
export * from './move.entity';
@@ -59,8 +55,6 @@ export const databaseEntities = [
AuditEntity,
ExifEntity,
GeodataPlacesEntity,
GeodataAdmin1Entity,
GeodataAdmin2Entity,
MoveEntity,
PartnerEntity,
PersonEntity,
@@ -0,0 +1,152 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class GeodataLocationSearch1708059341865 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS unaccent`);
// https://stackoverflow.com/a/11007216
await queryRunner.query(`
CREATE OR REPLACE FUNCTION f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
RETURN unaccent('unaccent', $1)`);
await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin1Name" varchar`);
await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin2Name" varchar`);
await queryRunner.query(`
UPDATE geodata_places
SET "admin1Name" = admin1.name
FROM geodata_admin1 admin1
WHERE admin1.key = "admin1Key"`);
await queryRunner.query(`
UPDATE geodata_places
SET "admin2Name" = admin2.name
FROM geodata_admin2 admin2
WHERE admin2.key = "admin2Key"`);
await queryRunner.query(`DROP TABLE geodata_admin1 CASCADE`);
await queryRunner.query(`DROP TABLE geodata_admin2 CASCADE`);
await queryRunner.query(`
ALTER TABLE geodata_places
DROP COLUMN "admin1Key",
DROP COLUMN "admin2Key"`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_name
ON geodata_places
USING gin (f_unaccent(name) gin_trgm_ops)`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_admin1_name
ON geodata_places
USING gin (f_unaccent("admin1Name") gin_trgm_ops)`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_admin2_name
ON geodata_places
USING gin (f_unaccent("admin2Name") gin_trgm_ops)`);
await queryRunner.query(
`
DELETE FROM "typeorm_metadata"
WHERE
"type" = $1 AND
"name" = $2 AND
"database" = $3 AND
"schema" = $4 AND
"table" = $5`,
['GENERATED_COLUMN', 'admin1Key', 'immich', 'public', 'geodata_places'],
);
await queryRunner.query(
`
DELETE FROM "typeorm_metadata"
WHERE
"type" = $1 AND
"name" = $2 AND
"database" = $3 AND
"schema" = $4 AND
"table" = $5`,
['GENERATED_COLUMN', 'admin2Key', 'immich', 'public', 'geodata_places'],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "geodata_admin1" (
"key" character varying NOT NULL,
"name" character varying NOT NULL,
CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key")
)`);
await queryRunner.query(`
CREATE TABLE "geodata_admin2" (
"key" character varying NOT NULL,
"name" character varying NOT NULL,
CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key")
)`);
await queryRunner.query(`
ALTER TABLE geodata_places
ADD COLUMN "admin1Key" character varying
GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED,
ADD COLUMN "admin2Key" character varying
GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED`);
await queryRunner.query(
`
INSERT INTO "geodata_admin1"
SELECT DISTINCT
"admin1Key" AS "key",
"admin1Name" AS "name"
FROM geodata_places
WHERE "admin1Name" IS NOT NULL`,
);
await queryRunner.query(
`
INSERT INTO "geodata_admin2"
SELECT DISTINCT
"admin2Key" AS "key",
"admin2Name" AS "name"
FROM geodata_places
WHERE "admin2Name" IS NOT NULL`,
);
await queryRunner.query(`
UPDATE geodata_places
SET "admin1Name" = admin1.name
FROM geodata_admin1 admin1
WHERE admin1.key = "admin1Key"`);
await queryRunner.query(`
UPDATE geodata_places
SET "admin2Name" = admin2.name
FROM geodata_admin2 admin2
WHERE admin2.key = "admin2Key";`);
await queryRunner.query(
`
INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
VALUES ($1, $2, $3, $4, $5, $6)`,
['immich', 'public', 'geodata_places', 'GENERATED_COLUMN', 'admin1Key', '"countryCode" || \'.\' || "admin1Code"'],
);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
VALUES ($1, $2, $3, $4, $5, $6)`,
[
'immich',
'public',
'geodata_places',
'GENERATED_COLUMN',
'admin2Key',
'"countryCode" || \'.\' || "admin1Code" || \'.\' || "admin2Code"',
],
);
}
}
@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class GeonamesEnhancement1708116312820 implements MigrationInterface {
name = 'GeonamesEnhancement1708116312820'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "alternateNames" varchar`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_admin2_alternate_names
ON geodata_places
USING gin (f_unaccent("alternateNames") gin_trgm_ops)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "alternateNames"`);
}
}
@@ -76,18 +76,7 @@ export class MediaRepository implements IMediaRepository {
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
const oldLdLibraryPath = process.env.LD_LIBRARY_PATH;
if (options.ldLibraryPath) {
// fluent ffmpeg does not allow to set environment variables, so we do it manually
process.env.LD_LIBRARY_PATH = this.chainPath(oldLdLibraryPath || '', options.ldLibraryPath);
}
try {
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
} finally {
if (options.ldLibraryPath) {
process.env.LD_LIBRARY_PATH = oldLdLibraryPath;
}
}
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
});
}
@@ -121,7 +110,6 @@ export class MediaRepository implements IMediaRepository {
configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) {
return ffmpeg(input, { niceness: 10 })
.setFfmpegPath(options.ffmpegPath || 'ffmpeg')
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)
.output(output)
@@ -2,7 +2,7 @@ import {
citiesFile,
geodataAdmin1Path,
geodataAdmin2Path,
geodataCitites500Path,
geodataCities500Path,
geodataDatePath,
GeoPoint,
IMetadataRepository,
@@ -10,13 +10,7 @@ import {
ISystemMetadataRepository,
ReverseGeocodeResult,
} from '@app/domain';
import {
ExifEntity,
GeodataAdmin1Entity,
GeodataAdmin2Entity,
GeodataPlacesEntity,
SystemMetadataKey,
} from '@app/infra/entities';
import { ExifEntity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@@ -26,19 +20,16 @@ import { getName } from 'i18n-iso-countries';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline';
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
import { DataSource, QueryRunner, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
import { DummyValue, GenerateSql } from '../infra.util';
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
export class MetadataRepository implements IMetadataRepository {
constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>,
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>,
@Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
@Inject(ISystemMetadataRepository)
private readonly systemMetadataRepository: ISystemMetadataRepository,
@InjectDataSource() private dataSource: DataSource,
) {}
@@ -54,7 +45,6 @@ export class MetadataRepository implements IMetadataRepository {
return;
}
this.logger.log('Importing geodata to database from file');
await this.importGeodata();
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
@@ -69,12 +59,14 @@ export class MetadataRepository implements IMetadataRepository {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
const admin1 = await this.loadAdmin(geodataAdmin1Path);
const admin2 = await this.loadAdmin(geodataAdmin2Path);
try {
await queryRunner.startTransaction();
await this.loadCities500(queryRunner);
await this.loadAdmin1(queryRunner);
await this.loadAdmin2(queryRunner);
await queryRunner.manager.clear(GeodataPlacesEntity);
await this.loadCities500(queryRunner, admin1, admin2);
await queryRunner.commitTransaction();
} catch (error) {
@@ -86,76 +78,73 @@ export class MetadataRepository implements IMetadataRepository {
}
}
private async loadGeodataToTableFromFile<T extends GeoEntity>(
private async loadGeodataToTableFromFile(
queryRunner: QueryRunner,
lineToEntityMapper: (lineSplit: string[]) => T,
lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity,
filePath: string,
entity: GeoEntityClass,
) {
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`);
}
await queryRunner.manager.clear(entity);
const input = createReadStream(filePath);
let buffer: DeepPartial<T>[] = [];
const lineReader = readLine.createInterface({ input: input });
let bufferGeodata: QueryDeepPartialEntity<GeodataPlacesEntity>[] = [];
const lineReader = readLine.createInterface({ input });
for await (const line of lineReader) {
const lineSplit = line.split('\t');
buffer.push(lineToEntityMapper(lineSplit));
if (buffer.length > 1000) {
await queryRunner.manager.save(buffer);
buffer = [];
const geoData = lineToEntityMapper(lineSplit);
bufferGeodata.push(geoData);
if (bufferGeodata.length > 1000) {
await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
bufferGeodata = [];
}
}
await queryRunner.manager.save(buffer);
await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
}
private async loadCities500(queryRunner: QueryRunner) {
await this.loadGeodataToTableFromFile<GeodataPlacesEntity>(
private async loadCities500(
queryRunner: QueryRunner,
admin1Map: Map<string, string>,
admin2Map: Map<string, string>,
) {
await this.loadGeodataToTableFromFile(
queryRunner,
(lineSplit: string[]) =>
this.geodataPlacesRepository.create({
id: Number.parseInt(lineSplit[0]),
name: lineSplit[1],
alternateNames: lineSplit[3],
latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8],
admin1Code: lineSplit[10],
admin2Code: lineSplit[11],
modificationDate: lineSplit[18],
admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
}),
geodataCitites500Path,
GeodataPlacesEntity,
geodataCities500Path,
);
}
private async loadAdmin1(queryRunner: QueryRunner) {
await this.loadGeodataToTableFromFile<GeodataAdmin1Entity>(
queryRunner,
(lineSplit: string[]) =>
this.geodataAdmin1Repository.create({
key: lineSplit[0],
name: lineSplit[1],
}),
geodataAdmin1Path,
GeodataAdmin1Entity,
);
}
private async loadAdmin(filePath: string) {
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`);
}
private async loadAdmin2(queryRunner: QueryRunner) {
await this.loadGeodataToTableFromFile<GeodataAdmin2Entity>(
queryRunner,
(lineSplit: string[]) =>
this.geodataAdmin2Repository.create({
key: lineSplit[0],
name: lineSplit[1],
}),
geodataAdmin2Path,
GeodataAdmin2Entity,
);
const input = createReadStream(filePath);
const lineReader = readLine.createInterface({ input: input });
const adminMap = new Map<string, string>();
for await (const line of lineReader) {
const lineSplit = line.split('\t');
adminMap.set(lineSplit[0], lineSplit[1]);
}
return adminMap;
}
async teardown() {
@@ -167,8 +156,6 @@ export class MetadataRepository implements IMetadataRepository {
const response = await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
.leftJoinAndSelect('geoplaces.admin1', 'admin1')
.leftJoinAndSelect('geoplaces.admin2', 'admin2')
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
.limit(1)
@@ -183,9 +170,9 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
const { countryCode, name: city, admin1, admin2 } = response;
const { countryCode, name: city, admin1Name, admin2Name } = response;
const country = getName(countryCode, 'en') ?? null;
const stateParts = [admin2?.name, admin1?.name].filter((name) => !!name);
const stateParts = [admin2Name, admin1Name].filter((name) => !!name);
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
return { country, state, city };
@@ -40,11 +40,11 @@ export class PersonRepository implements IPersonRepository {
}
async deleteAll(): Promise<void> {
await this.personRepository.delete({});
await this.personRepository.clear();
}
async deleteAllFaces(): Promise<void> {
await this.assetFaceRepository.delete({});
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
}
getAllFaces(
@@ -12,7 +12,13 @@ import {
SmartSearchOptions,
} from '@app/domain';
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
import {
AssetEntity,
AssetFaceEntity,
GeodataPlacesEntity,
SmartInfoEntity,
SmartSearchEntity,
} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -31,6 +37,7 @@ export class SearchRepository implements ISearchRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
) {
this.faceColumns = this.assetFaceRepository.manager.connection
.getMetadata(AssetFaceEntity)
@@ -172,6 +179,27 @@ export class SearchRepository implements ISearchRepository {
}));
}
@GenerateSql({ params: [DummyValue.STRING] })
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
return await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
.where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
.orderBy(
`
COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
`,
)
.setParameters({ placeName })
.limit(20)
.getMany();
}
async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
if (!smartInfo.assetId || !embedding) {
@@ -201,25 +229,17 @@ export class SearchRepository implements ISearchRepository {
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
await this.smartSearchRepository.manager.transaction(async (manager) => {
await manager.query(`DROP TABLE smart_search`);
await manager.query(`
CREATE TABLE smart_search (
"assetId" uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,
embedding vector(${dimSize}) NOT NULL )`);
await manager.query(`
CREATE INDEX clip_index ON smart_search
USING vectors (embedding vector_cos_ops) WITH (options = $$
[indexing.hnsw]
m = 16
ef_construction = 300
$$)`);
await manager.clear(SmartSearchEntity);
await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
});
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
}
deleteAllSearchEmbeddings(): Promise<void> {
return this.smartSearchRepository.clear();
}
private async getDimSize(): Promise<number> {
const res = await this.smartSearchRepository.manager.query(`
SELECT atttypmod as dimsize
@@ -238,3 +238,37 @@ FROM
WHERE
res.distance <= $3
COMMIT
-- SearchRepository.searchPlaces
SELECT
"geoplaces"."id" AS "geoplaces_id",
"geoplaces"."name" AS "geoplaces_name",
"geoplaces"."longitude" AS "geoplaces_longitude",
"geoplaces"."latitude" AS "geoplaces_latitude",
"geoplaces"."countryCode" AS "geoplaces_countryCode",
"geoplaces"."admin1Code" AS "geoplaces_admin1Code",
"geoplaces"."admin2Code" AS "geoplaces_admin2Code",
"geoplaces"."admin1Name" AS "geoplaces_admin1Name",
"geoplaces"."admin2Name" AS "geoplaces_admin2Name",
"geoplaces"."alternateNames" AS "geoplaces_alternateNames",
"geoplaces"."modificationDate" AS "geoplaces_modificationDate"
FROM
"geodata_places" "geoplaces"
WHERE
f_unaccent (name) %>> f_unaccent ($1)
OR f_unaccent ("admin2Name") %>> f_unaccent ($1)
OR f_unaccent ("admin1Name") %>> f_unaccent ($1)
OR f_unaccent ("alternateNames") %>> f_unaccent ($1)
ORDER BY
COALESCE(f_unaccent (name) <->>> f_unaccent ($1), 0) + COALESCE(
f_unaccent ("admin2Name") <->>> f_unaccent ($1),
0
) + COALESCE(
f_unaccent ("admin1Name") <->>> f_unaccent ($1),
0
) + COALESCE(
f_unaccent ("alternateNames") <->>> f_unaccent ($1),
0
) ASC
LIMIT
20
@@ -7,5 +7,7 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
searchSmart: jest.fn(),
searchFaces: jest.fn(),
upsert: jest.fn(),
searchPlaces: jest.fn(),
deleteAllSearchEmbeddings: jest.fn(),
};
};