feat: use pgvecto.rs (#3605)

This commit is contained in:
Jason Rasmussen
2023-12-08 11:15:46 -05:00
committed by GitHub
parent 429ad28810
commit 1e99ba8167
99 changed files with 1935 additions and 2583 deletions
+41
View File
@@ -0,0 +1,41 @@
import { dataSource } from '@app/infra';
import AsyncLock from 'async-lock';
export enum DatabaseLock {
GeodataImport = 100,
CLIPDimSize = 512,
}
export async function acquireLock(lock: DatabaseLock): Promise<void> {
return dataSource.query('SELECT pg_advisory_lock($1)', [lock]);
}
export async function releaseLock(lock: DatabaseLock): Promise<void> {
return dataSource.query('SELECT pg_advisory_unlock($1)', [lock]);
}
export const asyncLock = new AsyncLock();
export function RequireLock<T>(
lock: DatabaseLock,
): (target: any, propertyKey: string, descriptor: PropertyDescriptor) => void {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor): void {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]): Promise<T> {
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
let res;
await asyncLock.acquire(DatabaseLock[lock], async () => {
try {
await acquireLock(lock);
res = await originalMethod.apply(this, args);
} finally {
await releaseLock(lock);
}
});
return res as any;
};
};
}
+7
View File
@@ -25,3 +25,10 @@ export const databaseConfig: PostgresConnectionOptions = {
// this export is used by TypeORM commands in package.json#scripts
export const dataSource = new DataSource(databaseConfig);
export async function enablePrefilter() {
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
await dataSource.query(`SET vectors.enable_prefilter = on`);
}
@@ -2,7 +2,7 @@ import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeor
import { AssetEntity } from './asset.entity';
import { PersonEntity } from './person.entity';
@Entity('asset_faces')
@Entity('asset_faces', { synchronize: false })
@Index(['personId', 'assetId'])
export class AssetFaceEntity {
@PrimaryGeneratedColumn('uuid')
@@ -14,12 +14,9 @@ export class AssetFaceEntity {
@Column({ nullable: true, type: 'uuid' })
personId!: string | null;
@Column({
type: 'float4',
array: true,
nullable: true,
})
embedding!: number[] | null;
@Index('face_index', { synchronize: false })
@Column({ type: 'float4', array: true, select: false })
embedding!: number[];
@Column({ default: 0, type: 'int' })
imageWidth!: number;
@@ -20,6 +20,7 @@ import { ExifEntity } from './exif.entity';
import { LibraryEntity } from './library.entity';
import { SharedLinkEntity } from './shared-link.entity';
import { SmartInfoEntity } from './smart-info.entity';
import { SmartSearchEntity } from './smart-search.entity';
import { TagEntity } from './tag.entity';
import { UserEntity } from './user.entity';
@@ -137,6 +138,9 @@ export class AssetEntity {
@OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
smartInfo?: SmartInfoEntity;
@OneToOne(() => SmartSearchEntity, (smartSearchEntity) => smartSearchEntity.asset)
smartSearch?: SmartSearchEntity;
@ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
@JoinTable({ name: 'tag_asset' })
tags!: TagEntity[];
+2
View File
@@ -46,6 +46,7 @@ export class ExifEntity {
@Column({ type: 'varchar', nullable: true })
projectionType!: string | null;
@Index('exif_city')
@Column({ type: 'varchar', nullable: true })
city!: string | null;
@@ -98,6 +99,7 @@ export class ExifEntity {
@Column({
type: 'tsvector',
generatedType: 'STORED',
select: false,
asExpression: `TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
+3
View File
@@ -15,6 +15,7 @@ import { PartnerEntity } from './partner.entity';
import { PersonEntity } from './person.entity';
import { SharedLinkEntity } from './shared-link.entity';
import { SmartInfoEntity } from './smart-info.entity';
import { SmartSearchEntity } from './smart-search.entity';
import { SystemConfigEntity } from './system-config.entity';
import { SystemMetadataEntity } from './system-metadata.entity';
import { TagEntity } from './tag.entity';
@@ -38,6 +39,7 @@ export * from './partner.entity';
export * from './person.entity';
export * from './shared-link.entity';
export * from './smart-info.entity';
export * from './smart-search.entity';
export * from './system-config.entity';
export * from './system-metadata.entity';
export * from './tag.entity';
@@ -61,6 +63,7 @@ export const databaseEntities = [
PersonEntity,
SharedLinkEntity,
SmartInfoEntity,
SmartSearchEntity,
SystemConfigEntity,
SystemMetadataEntity,
TagEntity,
@@ -1,7 +1,7 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
import { AssetEntity } from './asset.entity';
@Entity('smart_info')
@Entity('smart_info', { synchronize: false })
export class SmartInfoEntity {
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
@@ -15,11 +15,4 @@ export class SmartInfoEntity {
@Column({ type: 'text', array: true, nullable: true })
objects!: string[] | null;
@Column({
type: 'float4',
array: true,
nullable: true,
})
clipEmbedding!: number[] | null;
}
@@ -0,0 +1,20 @@
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
import { AssetEntity } from './asset.entity';
@Entity('smart_search', { synchronize: false })
export class SmartSearchEntity {
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset?: AssetEntity;
@PrimaryColumn()
assetId!: string;
@Index('clip_index', { synchronize: false })
@Column({
type: 'float4',
array: true,
select: false,
})
embedding!: number[];
}
+1
View File
@@ -1,3 +1,4 @@
export * from './database-locks';
export * from './database.config';
export * from './infra.config';
export * from './infra.module';
-34
View File
@@ -2,7 +2,6 @@ import { QueueName } from '@app/domain';
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { QueueOptions } from 'bullmq';
import { RedisOptions } from 'ioredis';
import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration';
function parseRedisConfig(): RedisOptions {
if (process.env.IMMICH_TEST_ENV == 'true') {
@@ -41,36 +40,3 @@ export const bullConfig: QueueOptions = {
};
export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name }));
function parseTypeSenseConfig(): ConfigurationOptions {
const typesenseURL = process.env.TYPESENSE_URL;
const common = {
apiKey: process.env.TYPESENSE_API_KEY as string,
numRetries: 15,
retryIntervalSeconds: 4,
connectionTimeoutSeconds: 10,
};
if (typesenseURL && typesenseURL.startsWith('ha://')) {
try {
const decodedString = Buffer.from(typesenseURL.slice(5), 'base64').toString();
return {
nodes: JSON.parse(decodedString),
...common,
};
} catch (error) {
throw new Error(`Failed to decode typesense options: ${error}`);
}
}
return {
nodes: [
{
host: process.env.TYPESENSE_HOST || 'typesense',
port: Number(process.env.TYPESENSE_PORT) || 8108,
protocol: process.env.TYPESENSE_PROTOCOL || 'http',
},
],
...common,
};
}
export const typesenseConfig: ConfigurationOptions = parseTypeSenseConfig();
-3
View File
@@ -15,7 +15,6 @@ import {
IMoveRepository,
IPartnerRepository,
IPersonRepository,
ISearchRepository,
IServerInfoRepository,
ISharedLinkRepository,
ISmartInfoRepository,
@@ -59,7 +58,6 @@ import {
SystemConfigRepository,
SystemMetadataRepository,
TagRepository,
TypesenseRepository,
UserRepository,
UserTokenRepository,
} from './repositories';
@@ -80,7 +78,6 @@ const providers: Provider[] = [
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository },
{ provide: ISearchRepository, useClass: TypesenseRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },
+42
View File
@@ -0,0 +1,42 @@
import { Paginated, PaginationOptions } from '@app/domain';
import { Between, FindOneOptions, LessThanOrEqual, MoreThanOrEqual, ObjectLiteral, Repository } from 'typeorm';
/**
* Allows optional values unlike the regular Between and uses MoreThanOrEqual
* or LessThanOrEqual when only one parameter is specified.
*/
export function OptionalBetween<T>(from?: T, to?: T) {
if (from && to) {
return Between(from, to);
} else if (from) {
return MoreThanOrEqual(from);
} else if (to) {
return LessThanOrEqual(to);
}
}
export async function paginate<Entity extends ObjectLiteral>(
repository: Repository<Entity>,
paginationOptions: PaginationOptions,
searchOptions?: FindOneOptions<Entity>,
): Paginated<Entity> {
const items = await repository.find({
...searchOptions,
// Take one more item to check if there's a next page
take: paginationOptions.take + 1,
skip: paginationOptions.skip,
});
const hasNextPage = items.length > paginationOptions.take;
items.splice(paginationOptions.take);
return { items, hasNextPage };
}
export const asVector = (embedding: number[], quote = false) =>
quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`;
export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
return Number.isInteger(value) && value >= min && value <= max;
};
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UsePgVectors1700713871511 implements MigrationInterface {
name = 'UsePgVectors1700713871511';
public async up(queryRunner: QueryRunner): Promise<void> {
const faceDimQuery = await queryRunner.query(`
SELECT CARDINALITY(embedding::real[]) as dimsize
FROM asset_faces
LIMIT 1`);
const clipDimQuery = await queryRunner.query(`
SELECT CARDINALITY("clipEmbedding"::real[]) as dimsize
FROM smart_info
LIMIT 1`);
const faceDimSize = faceDimQuery?.[0]?.['dimsize'] ?? 512;
const clipDimSize = clipDimQuery?.[0]?.['dimsize'] ?? 512;
await queryRunner.query('CREATE EXTENSION IF NOT EXISTS vectors');
await queryRunner.query(`
ALTER TABLE asset_faces
ALTER COLUMN embedding SET NOT NULL,
ALTER COLUMN embedding TYPE vector(${faceDimSize})`);
await queryRunner.query(`
CREATE TABLE smart_search (
"assetId" uuid PRIMARY KEY NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
embedding vector(${clipDimSize}) NOT NULL )`);
await queryRunner.query(`
INSERT INTO smart_search("assetId", embedding)
SELECT si."assetId", si."clipEmbedding"
FROM smart_info si
WHERE "clipEmbedding" IS NOT NULL`);
await queryRunner.query(`ALTER TABLE smart_info DROP COLUMN IF EXISTS "clipEmbedding"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE asset_faces ALTER COLUMN embedding TYPE real array`);
await queryRunner.query(`ALTER TABLE smart_info ADD COLUMN IF NOT EXISTS "clipEmbedding" TYPE real array`);
await queryRunner.query(`
INSERT INTO smart_info
("assetId", "clipEmbedding")
SELECT s."assetId", s.embedding
FROM smart_search s
ON CONFLICT (s."assetId") DO UPDATE SET "clipEmbedding" = s.embedding`);
await queryRunner.query(`DROP TABLE IF EXISTS smart_search`);
}
}
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface {
name = 'AddCLIPEmbeddingIndex1700713994428';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS clip_index ON smart_search
USING vectors (embedding cosine_ops) WITH (options = $$
[indexing.hnsw]
m = 16
ef_construction = 300
$$);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS clip_index`);
}
}
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface {
name = 'AddFaceEmbeddingIndex1700714033632';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS face_index ON asset_faces
USING vectors (embedding cosine_ops) WITH (options = $$
[indexing.hnsw]
m = 16
ef_construction = 300
$$);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS face_index`);
}
}
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSmartInfoTagsIndex1700714072055 implements MigrationInterface {
name = 'AddSmartInfoTagsIndex1700714072055';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE INDEX IF NOT EXISTS si_tags ON smart_info USING GIN (tags);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS si_tags;`);
}
}
@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSmartInfoTextSearchIndex1700714140297 implements MigrationInterface {
name = 'CreateSmartInfoTextSearchIndex1700714140297';
public async up(queryRunner: QueryRunner): Promise<void> {
// https://dba.stackexchange.com/a/164081
await queryRunner.query(`
CREATE OR REPLACE FUNCTION f_concat_ws(text, text[])
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
'SELECT array_to_string($2, $1)'`);
await queryRunner.query(`
ALTER TABLE smart_info ADD "smartInfoTextSearchableColumn" tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR(
'english',
f_concat_ws(
' '::text,
COALESCE(tags, array[]::text[]) || COALESCE(objects, array[]::text[])
)
)
)
STORED NOT NULL`);
await queryRunner.query(`
CREATE INDEX smart_info_text_searchable_idx
ON smart_info
USING GIN ("smartInfoTextSearchableColumn")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP FUNCTION IF EXISTS immutable_concat_ws`);
await queryRunner.query(`ALTER TABLE smart_info DROP IF EXISTS "smartInfoTextSearchableColumn"`);
}
}
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddExifCityIndex1701665867595 implements MigrationInterface {
name = 'AddExifCityIndex1701665867595'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE INDEX "exif_city" ON "exif" ("city") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."exif_city"`);
}
}
+134 -17
View File
@@ -1,5 +1,7 @@
import {
AssetBuilderOptions,
AssetCreate,
AssetExploreFieldOptions,
AssetSearchOptions,
AssetStats,
AssetStatsOptions,
@@ -7,24 +9,25 @@ import {
LivePhotoSearchOptions,
MapMarker,
MapMarkerSearchOptions,
MetadataSearchOptions,
MonthDay,
Paginated,
PaginationOptions,
SearchExploreItem,
TimeBucketItem,
TimeBucketOptions,
TimeBucketSize,
WithoutProperty,
WithProperty,
WithoutProperty,
} from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
import { DateTime } from 'luxon';
import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '../entities';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util';
import OptionalBetween from '../utils/optional-between.util';
import { paginate } from '../utils/pagination.util';
import { OptionalBetween, paginate } from '../infra.utils';
const DEFAULT_SEARCH_SIZE = 250;
@@ -44,6 +47,7 @@ export class AssetRepository implements IAssetRepository {
@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
@InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository<SmartInfoEntity>,
) {}
async upsertExif(exif: Partial<ExifEntity>): Promise<void> {
@@ -356,16 +360,20 @@ export class AssetRepository implements IAssetRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { id },
relations: {
getById(id: string, relations: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null> {
if (!relations) {
relations = {
faces: {
person: true,
},
library: true,
stack: true,
},
};
}
return this.repository.findOne({
where: { id },
relations,
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
});
@@ -472,13 +480,13 @@ export class AssetRepository implements IAssetRepository {
case WithoutProperty.CLIP_ENCODING:
relations = {
smartInfo: true,
smartSearch: true,
};
where = {
isVisible: true,
resizePath: Not(IsNull()),
smartInfo: {
clipEmbedding: IsNull(),
smartSearch: {
embedding: IsNull(),
},
};
break;
@@ -689,15 +697,82 @@ export class AssetRepository implements IAssetRepository {
);
}
private getBuilder(options: TimeBucketOptions) {
const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked } = options;
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByCity(
ownerId: string,
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
): Promise<SearchExploreItem<string>> {
const cte = this.exifRepository
.createQueryBuilder('e')
.select('city')
.groupBy('city')
.having('count(city) >= :minAssetsPerField', { minAssetsPerField })
.orderBy('random()')
.limit(maxFields);
const items = await this.getBuilder({
userIds: [ownerId],
exifInfo: false,
assetType: AssetType.IMAGE,
isArchived: false,
})
.select('c.city', 'value')
.addSelect('asset.id', 'data')
.distinctOn(['c.city'])
.innerJoin('exif', 'e', 'asset.id = e."assetId"')
.addCommonTableExpression(cte, 'cities')
.innerJoin('cities', 'c', 'c.city = e.city')
.limit(maxFields)
.getRawMany();
return { fieldName: 'exifInfo.city', items };
}
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByTag(
ownerId: string,
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
): Promise<SearchExploreItem<string>> {
const cte = this.smartInfoRepository
.createQueryBuilder('si')
.select('unnest(tags)', 'tag')
.groupBy('tag')
.having('count(*) >= :minAssetsPerField', { minAssetsPerField })
.orderBy('random()')
.limit(maxFields);
const items = await this.getBuilder({
userIds: [ownerId],
exifInfo: false,
assetType: AssetType.IMAGE,
isArchived: false,
})
.select('unnest(si.tags)', 'value')
.addSelect('asset.id', 'data')
.distinctOn(['unnest(si.tags)'])
.innerJoin('smart_info', 'si', 'asset.id = si."assetId"')
.addCommonTableExpression(cte, 'random_tags')
.innerJoin('random_tags', 't', 'si.tags @> ARRAY[t.tag]')
.limit(maxFields)
.getRawMany();
return { fieldName: 'smartInfo.tags', items };
}
private getBuilder(options: AssetBuilderOptions) {
const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options;
let builder = this.repository
.createQueryBuilder('asset')
.where('asset.isVisible = true')
.andWhere('asset.fileCreatedAt < NOW()')
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.leftJoinAndSelect('asset.stack', 'stack');
.andWhere('asset.fileCreatedAt < NOW()');
if (assetType !== undefined) {
builder = builder.andWhere('asset.type = :assetType', { assetType });
}
if (exifInfo !== false) {
builder = builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo').leftJoinAndSelect('asset.stack', 'stack');
}
if (albumId) {
builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId });
@@ -732,4 +807,46 @@ export class AssetRepository implements IAssetRepository {
return builder;
}
async searchMetadata(query: string, ownerId: string, { numResults }: MetadataSearchOptions): Promise<AssetEntity[]> {
const rows = await this.repository
.createQueryBuilder('assets')
.select('assets.*')
.addSelect('e.country', 'country')
.addSelect('e.state', 'state')
.addSelect('e.city', 'city')
.addSelect('e.description', 'description')
.addSelect('e.model', 'model')
.addSelect('e.make', 'make')
.addSelect('COALESCE(si.tags, array[]::text[])', 'tags')
.addSelect('COALESCE(si.objects, array[]::text[])', 'objects')
.innerJoin('smart_info', 'si', 'si."assetId" = assets."id"')
.innerJoin('exif', 'e', 'assets."id" = e."assetId"')
.where('a.ownerId = :ownerId', { ownerId })
.where(
'(e."exifTextSearchableColumn" || si."smartInfoTextSearchableColumn") @@ PLAINTO_TSQUERY(\'english\', :query)',
{ query },
)
.limit(numResults)
.getRawMany();
return rows.map(
({ tags, objects, country, state, city, description, model, make, ...assetInfo }) =>
({
exifInfo: {
country,
state,
city,
description,
model,
make,
},
smartInfo: {
tags,
objects,
},
...assetInfo,
}) as AssetEntity,
);
}
}
-1
View File
@@ -21,6 +21,5 @@ export * from './smart-info.repository';
export * from './system-config.repository';
export * from './system-metadata.repository';
export * from './tag.repository';
export * from './typesense.repository';
export * from './user-token.repository';
export * from './user.repository';
@@ -5,8 +5,8 @@ import {
ISystemMetadataRepository,
ReverseGeocodeResult,
} from '@app/domain';
import { DatabaseLock, RequireLock } from '@app/infra';
import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import { DatabaseLock } from '@app/infra/utils/database-locks';
import { Inject, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
@@ -33,16 +33,14 @@ export class MetadataRepository implements IMetadataRepository {
private logger = new Logger(MetadataRepository.name);
@RequireLock(DatabaseLock.GeodataImport)
async init(): Promise<void> {
this.logger.log('Initializing metadata repository');
const geodataDate = await readFile('/usr/src/resources/geodata-date.txt', 'utf8');
await this.geodataPlacesRepository.query('SELECT pg_advisory_lock($1)', [DatabaseLock.GeodataImport]);
const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
if (geocodingMetadata?.lastUpdate === geodataDate) {
await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]);
return;
}
@@ -72,7 +70,6 @@ export class MetadataRepository implements IMetadataRepository {
lastImportFileName: CITIES_FILE,
});
await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]);
this.logger.log('Geodata import completed');
}
@@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util';
import { asVector } from '../infra.utils';
export class PersonRepository implements IPersonRepository {
constructor(
@@ -215,8 +216,15 @@ export class PersonRepository implements IPersonRepository {
return this.personRepository.save(entity);
}
createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity> {
return this.assetFaceRepository.save(entity);
async createFace(entity: AssetFaceEntity): Promise<AssetFaceEntity> {
if (!entity.personId) {
throw new Error('Person ID is required to create a face');
}
if (!entity.embedding) {
throw new Error('Embedding is required to create a face');
}
await this.assetFaceRepository.insert({ ...entity, embedding: () => asVector(entity.embedding, true) });
return this.assetFaceRepository.findOneByOrFail({ assetId: entity.assetId, personId: entity.personId });
}
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
@@ -1,14 +1,171 @@
import { ISmartInfoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { Embedding, EmbeddingSearch, ISmartInfoRepository } from '@app/domain';
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
import { DatabaseLock, RequireLock, asyncLock } from '@app/infra';
import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SmartInfoEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util';
import { asVector, isValidInteger } from '../infra.utils';
@Injectable()
export class SmartInfoRepository implements ISmartInfoRepository {
constructor(@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>) {}
private logger = new Logger(SmartInfoRepository.name);
async upsert(info: Partial<SmartInfoEntity>): Promise<void> {
await this.repository.upsert(info, { conflictPaths: ['assetId'] });
constructor(
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
) {}
async init(modelName: string): Promise<void> {
const { dimSize } = getCLIPModelInfo(modelName);
if (dimSize == null) {
throw new Error(`Invalid CLIP model name: ${modelName}`);
}
const curDimSize = await this.getDimSize();
this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`);
if (dimSize != curDimSize) {
this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`);
await this.updateDimSize(dimSize);
}
}
@GenerateSql({
params: [{ ownerId: DummyValue.UUID, embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }],
})
async searchCLIP({ ownerId, embedding, numResults }: EmbeddingSearch): Promise<AssetEntity[]> {
if (!isValidInteger(numResults, { min: 1 })) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
let results: AssetEntity[] = [];
await this.assetRepository.manager.transaction(async (manager) => {
await manager.query(`SET LOCAL vectors.k = '${numResults}'`);
results = await manager
.createQueryBuilder(AssetEntity, 'a')
.innerJoin('a.smartSearch', 's')
.where('a.ownerId = :ownerId')
.leftJoinAndSelect('a.exifInfo', 'e')
.orderBy('s.embedding <=> :embedding')
.setParameters({ ownerId, embedding: asVector(embedding) })
.limit(numResults)
.getMany();
});
return results;
}
@GenerateSql({
params: [
{
ownerId: DummyValue.UUID,
embedding: Array.from({ length: 512 }, Math.random),
numResults: 100,
maxDistance: 0.6,
},
],
})
async searchFaces({ ownerId, embedding, numResults, maxDistance }: EmbeddingSearch): Promise<AssetFaceEntity[]> {
if (!isValidInteger(numResults, { min: 1 })) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
let results: AssetFaceEntity[] = [];
await this.assetRepository.manager.transaction(async (manager) => {
await manager.query(`SET LOCAL vectors.k = '${numResults}'`);
const cte = manager
.createQueryBuilder(AssetFaceEntity, 'faces')
.addSelect('1 + (faces.embedding <=> :embedding)', 'distance')
.innerJoin('faces.asset', 'asset')
.where('asset.ownerId = :ownerId')
.orderBy(`faces.embedding <=> :embedding`)
.setParameters({ ownerId, embedding: asVector(embedding) })
.limit(numResults);
results = await manager
.createQueryBuilder()
.select('res.*')
.addCommonTableExpression(cte, 'cte')
.from('cte', 'res')
.where('res.distance <= :maxDistance', { maxDistance })
.getRawMany();
});
return this.assetFaceRepository.create(results);
}
async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
if (!smartInfo.assetId || !embedding) {
return;
}
await this.upsertEmbedding(smartInfo.assetId, embedding);
}
private async upsertEmbedding(assetId: string, embedding: number[]): Promise<void> {
if (asyncLock.isBusy(DatabaseLock[DatabaseLock.CLIPDimSize])) {
this.logger.verbose(`Waiting for CLIP dimension size to be updated`);
await asyncLock.acquire(DatabaseLock[DatabaseLock.CLIPDimSize], () => {});
}
await this.smartSearchRepository.upsert(
{ assetId, embedding: () => asVector(embedding, true) },
{ conflictPaths: ['assetId'] },
);
}
@RequireLock(DatabaseLock.CLIPDimSize)
private async updateDimSize(dimSize: number): Promise<void> {
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
}
const curDimSize = await this.getDimSize();
if (curDimSize === dimSize) {
return;
}
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 cosine_ops) WITH (options = $$
[indexing.hnsw]
m = 16
ef_construction = 300
$$)`);
});
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
}
private async getDimSize(): Promise<number> {
const res = await this.smartSearchRepository.manager.query(`
SELECT atttypmod as dimsize
FROM pg_attribute f
JOIN pg_class c ON c.oid = f.attrelid
WHERE c.relkind = 'r'::char
AND f.attnum > 0
AND c.relname = 'smart_search'
AND f.attname = 'embedding'`);
const dimSize = res[0]['dimsize'];
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
throw new Error(`Could not retrieve CLIP dimension size`);
}
return dimSize;
}
}
@@ -1,503 +0,0 @@
import {
ISearchRepository,
OwnedFaceEntity,
SearchCollection,
SearchCollectionIndexStatus,
SearchExploreItem,
SearchFaceFilter,
SearchFilter,
SearchResult,
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import _, { Dictionary } from 'lodash';
import { catchError, filter, firstValueFrom, from, map, mergeMap, of, toArray } from 'rxjs';
import { Client } from 'typesense';
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
import { AlbumEntity, AssetEntity, AssetFaceEntity } from '../entities';
import { typesenseConfig } from '../infra.config';
import { albumSchema, assetSchema, faceSchema } from '../typesense-schemas';
function removeNil<T extends Dictionary<any>>(item: T): T {
_.forOwn(item, (value, key) => {
if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) {
delete item[key];
}
});
return item;
}
interface MultiSearchError {
code: number;
error: string;
}
interface CustomAssetEntity extends AssetEntity {
geo?: [number, number];
motion?: boolean;
people?: string[];
}
const schemaMap: Record<SearchCollection, CollectionCreateSchema> = {
[SearchCollection.ASSETS]: assetSchema,
[SearchCollection.ALBUMS]: albumSchema,
[SearchCollection.FACES]: faceSchema,
};
const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][];
@Injectable()
export class TypesenseRepository implements ISearchRepository {
private logger = new Logger(TypesenseRepository.name);
private _client: Client | null = null;
private _updateCLIPLock = false;
private get client(): Client {
if (!this._client) {
throw new Error('Typesense client not available (no apiKey was provided)');
}
return this._client;
}
constructor() {
if (!typesenseConfig.apiKey) {
return;
}
this._client = new Client(typesenseConfig);
}
async setup(): Promise<void> {
const collections = await this.client.collections().retrieve();
for (const collection of collections) {
this.logger.debug(`${collection.name} collection has ${collection.num_documents} documents`);
// await this.client.collections(collection.name).delete();
}
// upsert collections
for (const [collectionName, schema] of schemas) {
const collection = await this.client
.collections(schema.name)
.retrieve()
.catch(() => null);
if (!collection) {
this.logger.log(`Creating schema: ${collectionName}/${schema.name}`);
await this.client.collections().create(schema);
} else {
this.logger.log(`Schema up to date: ${collectionName}/${schema.name}`);
}
}
}
async checkMigrationStatus(): Promise<SearchCollectionIndexStatus> {
const migrationMap: SearchCollectionIndexStatus = {
[SearchCollection.ASSETS]: false,
[SearchCollection.ALBUMS]: false,
[SearchCollection.FACES]: false,
};
// check if alias is using the current schema
const { aliases } = await this.client.aliases().retrieve();
this.logger.log(`Alias mapping: ${JSON.stringify(aliases)}`);
for (const [aliasName, schema] of schemas) {
const match = aliases.find((alias) => alias.name === aliasName);
if (!match || match.collection_name !== schema.name) {
migrationMap[aliasName] = true;
}
}
this.logger.log(`Collections needing migration: ${JSON.stringify(migrationMap)}`);
return migrationMap;
}
async importAlbums(items: AlbumEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.ALBUMS, items, done);
}
async importAssets(items: AssetEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.ASSETS, items, done);
}
async importFaces(items: OwnedFaceEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.FACES, items, done);
}
private async import(
collection: SearchCollection,
items: AlbumEntity[] | AssetEntity[] | OwnedFaceEntity[],
done: boolean,
): Promise<void> {
try {
if (items.length > 0) {
await this.client.collections(schemaMap[collection].name).documents().import(this.patch(collection, items), {
action: 'upsert',
dirty_values: 'coerce_or_drop',
});
}
if (done) {
await this.updateAlias(collection);
}
} catch (error: any) {
await this.handleError(error);
}
}
async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> {
const common = {
q: '*',
filter_by: [this.buildFilterBy('ownerId', userId, true), this.buildFilterBy('isArchived', false)].join(' && '),
per_page: 100,
};
const asset$ = this.client.collections<AssetEntity>(assetSchema.name).documents();
const { facet_counts: facets } = await asset$.search({
...common,
query_by: 'originalFileName',
facet_by: 'exifInfo.city,smartInfo.objects',
max_facet_values: 12,
});
return firstValueFrom(
from(facets || []).pipe(
mergeMap(
(facet) =>
from(facet.counts).pipe(
mergeMap((count) => {
const config = {
...common,
query_by: 'originalFileName',
filter_by: [common.filter_by, this.buildFilterBy(facet.field_name, count.value, true)].join(' && '),
per_page: 1,
};
this.logger.verbose(`Explore subquery: "filter_by:${config.filter_by}" (count:${count.count})`);
return from(asset$.search(config)).pipe(
catchError((error: any) => {
this.logger.warn(`Explore subquery error: ${error}`, error?.stack);
return of({ hits: [] });
}),
map((result) => ({
value: count.value,
data: result.hits?.[0]?.document as AssetEntity,
})),
filter((item) => !!item.data),
);
}, 5),
toArray(),
map((items) => ({
fieldName: facet.field_name as string,
items,
})),
),
3,
),
toArray(),
),
);
}
async deleteAlbums(ids: string[]): Promise<void> {
await this.delete(SearchCollection.ALBUMS, ids);
}
async deleteAssets(ids: string[]): Promise<void> {
await this.delete(SearchCollection.ASSETS, ids);
}
async deleteFaces(ids: string[]): Promise<void> {
await this.delete(SearchCollection.FACES, ids);
}
async deleteAllFaces(): Promise<number> {
const records = await this.client.collections(faceSchema.name).documents().delete({ filter_by: 'ownerId:!=null' });
return records.num_deleted;
}
async deleteAllAssets(): Promise<number> {
const records = await this.client.collections(assetSchema.name).documents().delete({ filter_by: 'ownerId:!=null' });
return records.num_deleted;
}
async updateCLIPField(num_dim: number): Promise<void> {
const clipField = assetSchema.fields?.find((field) => field.name === 'smartInfo.clipEmbedding');
if (clipField && !this._updateCLIPLock) {
try {
this._updateCLIPLock = true;
clipField.num_dim = num_dim;
await this.deleteAllAssets();
await this.client
.collections(assetSchema.name)
.update({ fields: [{ name: 'smartInfo.clipEmbedding', drop: true } as any, clipField] });
this.logger.log(`Successfully updated CLIP dimensions to ${num_dim}`);
} catch (err: any) {
this.logger.error(`Error while updating CLIP field: ${err.message}`);
} finally {
this._updateCLIPLock = false;
}
}
}
async delete(collection: SearchCollection, ids: string[]): Promise<void> {
await this.client
.collections(schemaMap[collection].name)
.documents()
.delete({ filter_by: this.buildFilterBy('id', ids, true) });
}
async searchAlbums(query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>> {
const results = await this.client
.collections<AlbumEntity>(albumSchema.name)
.documents()
.search({
q: query,
query_by: ['albumName', 'description'].join(','),
filter_by: this.getAlbumFilters(filters),
});
return this.asResponse(results, filters.debug);
}
async searchAssets(query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
const results = await this.client
.collections<AssetEntity>(assetSchema.name)
.documents()
.search({
q: query,
query_by: [
'originalFileName',
'exifInfo.country',
'exifInfo.state',
'exifInfo.city',
'exifInfo.description',
'exifInfo.model',
'exifInfo.make',
'smartInfo.tags',
'smartInfo.objects',
'people',
].join(','),
per_page: 250,
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
filter_by: this.getAssetFilters(filters),
sort_by: filters.recent ? 'createdAt:desc' : undefined,
});
return this.asResponse(results, filters.debug);
}
async searchFaces(input: number[], filters: SearchFaceFilter): Promise<SearchResult<AssetFaceEntity>> {
const { results } = await this.client.multiSearch.perform({
searches: [
{
collection: faceSchema.name,
q: '*',
vector_query: `embedding:([${input.join(',')}], k:5)`,
per_page: 5,
filter_by: this.buildFilterBy('ownerId', filters.ownerId, true),
} as any,
],
});
return this.asResponse(results[0] as SearchResponse<AssetFaceEntity>);
}
async vectorSearch(input: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
const { results } = await this.client.multiSearch.perform({
searches: [
{
collection: assetSchema.name,
q: '*',
vector_query: `smartInfo.clipEmbedding:([${input.join(',')}], k:100)`,
per_page: 100,
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
filter_by: this.getAssetFilters(filters),
} as any,
],
});
return this.asResponse(results[0] as SearchResponse<AssetEntity>, filters.debug);
}
private asResponse<T extends DocumentSchema>(
resultsOrError: SearchResponse<T> | MultiSearchError,
debug?: boolean,
): SearchResult<T> {
const { error, code } = resultsOrError as MultiSearchError;
if (error) {
throw new Error(`Typesense multi-search error: ${code} - ${error}`);
}
const results = resultsOrError as SearchResponse<T>;
return {
page: results.page,
total: results.found,
count: results.out_of,
items: (results.hits || []).map((hit) => hit.document),
distances: (results.hits || []).map((hit: any) => hit.vector_distance),
facets: (results.facet_counts || []).map((facet) => ({
counts: facet.counts.map((item) => ({ count: item.count, value: item.value })),
fieldName: facet.field_name as string,
})),
debug: debug ? results : undefined,
} as SearchResult<T>;
}
private async handleError(error: any) {
this.logger.error('Unable to index documents');
const results = error.importResults || [];
let dimsChanged = false;
for (const result of results) {
try {
result.document = JSON.parse(result.document);
if (result.error.includes('Field `smartInfo.clipEmbedding` must have')) {
dimsChanged = true;
this.logger.warn(
`CLIP embedding dimensions have changed, now ${result.document.smartInfo.clipEmbedding.length} dims. Updating schema...`,
);
await this.updateCLIPField(result.document.smartInfo.clipEmbedding.length);
break;
}
if (result.document?.smartInfo?.clipEmbedding) {
result.document.smartInfo.clipEmbedding = '<truncated>';
}
} catch (err: any) {
this.logger.error(`Error while updating CLIP field: ${(err.message, err.stack)}`);
}
}
if (!dimsChanged) {
this.logger.log(JSON.stringify(results, null, 2));
}
}
private async updateAlias(collection: SearchCollection) {
const schema = schemaMap[collection];
const alias = await this.client
.aliases(collection)
.retrieve()
.catch(() => null);
// update alias to current collection
this.logger.log(`Using new schema: ${alias?.collection_name || '(unset)'} => ${schema.name}`);
await this.client.aliases().upsert(collection, { collection_name: schema.name });
// delete previous collection
if (alias && alias.collection_name !== schema.name) {
this.logger.log(`Deleting old schema: ${alias.collection_name}`);
await this.client.collections(alias.collection_name).delete();
}
}
private patch(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[] | OwnedFaceEntity[]) {
return items.map((item) => {
switch (collection) {
case SearchCollection.ASSETS:
return this.patchAsset(item as AssetEntity);
case SearchCollection.ALBUMS:
return this.patchAlbum(item as AlbumEntity);
case SearchCollection.FACES:
return this.patchFace(item as OwnedFaceEntity);
}
});
}
private patchAlbum(album: AlbumEntity): AlbumEntity {
return removeNil(album);
}
private patchAsset(asset: AssetEntity): CustomAssetEntity {
let custom = asset as CustomAssetEntity;
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng && lat !== 0 && lng !== 0) {
custom = { ...custom, geo: [lat, lng] };
}
const people = asset.faces
?.filter((face) => !face.person?.isHidden && face.person?.name)
.map((face) => face.person?.name)
.filter((name) => name !== undefined) as string[];
if (people.length) {
custom = { ...custom, people };
}
return removeNil({ ...custom, motion: !!asset.livePhotoVideoId });
}
private patchFace(face: OwnedFaceEntity): OwnedFaceEntity {
return removeNil(face);
}
private getFacetFieldNames(collection: SearchCollection) {
return (schemaMap[collection].fields || [])
.filter((field) => field.facet)
.map((field) => field.name)
.join(',');
}
private getAlbumFilters(filters: SearchFilter) {
const { userId } = filters;
const _filters = [this.buildFilterBy('ownerId', userId, true)];
if (filters.id) {
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of albumSchema.fields || []) {
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(this.buildFilterBy(item.name, value));
}
}
const result = _filters.join(' && ');
this.logger.debug(`Album filters are: ${result}`);
return result;
}
private getAssetFilters(filters: SearchFilter) {
const { userId } = filters;
const _filters = [this.buildFilterBy('ownerId', userId, true), this.buildFilterBy('isArchived', false)];
if (filters.id) {
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of assetSchema.fields || []) {
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(this.buildFilterBy(item.name, value));
}
}
const result = _filters.join(' && ');
this.logger.debug(`Asset filters are: ${result}`);
return result;
}
private buildFilterBy(key: string, values: boolean | string | string[], exact?: boolean) {
const token = exact ? ':=' : ':';
const _values = (Array.isArray(values) ? values : [values]).map((value) => {
if (typeof value === 'boolean' || value === 'true' || value === 'false') {
return value;
}
return '`' + value + '`';
});
const value = _values.length > 1 ? `[${_values.join(',')}]` : _values[0];
return `${key}${token}${value}`;
}
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { ISystemConfigRepository } from '@app/domain';
import { INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
@@ -18,6 +19,7 @@ import {
PartnerRepository,
PersonRepository,
SharedLinkRepository,
SmartInfoRepository,
SystemConfigRepository,
SystemMetadataRepository,
TagRepository,
@@ -38,6 +40,7 @@ const repositories = [
PartnerRepository,
PersonRepository,
SharedLinkRepository,
SmartInfoRepository,
SystemConfigRepository,
SystemMetadataRepository,
TagRepository,
@@ -82,7 +85,7 @@ class SqlGenerator {
}),
TypeOrmModule.forFeature(databaseEntities),
],
providers: repositories,
providers: [{ provide: ISystemConfigRepository, useClass: SystemConfigRepository }, ...repositories],
}).compile();
this.app = await moduleFixture.createNestApplication().init();
+72 -8
View File
@@ -57,8 +57,7 @@ SELECT
"AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription",
"AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace",
"AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample",
"AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps",
"AssetEntity__AssetEntity_exifInfo"."exifTextSearchableColumn" AS "AssetEntity__AssetEntity_exifInfo_exifTextSearchableColumn"
"AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps"
FROM
"assets" "AssetEntity"
LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id"
@@ -133,8 +132,7 @@ SELECT
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."fps" AS "exifInfo_fps",
"exifInfo"."exifTextSearchableColumn" AS "exifInfo_exifTextSearchableColumn"
"exifInfo"."fps" AS "exifInfo_fps"
FROM
"assets" "entity"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id"
@@ -217,11 +215,9 @@ SELECT
"AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace",
"AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample",
"AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps",
"AssetEntity__AssetEntity_exifInfo"."exifTextSearchableColumn" AS "AssetEntity__AssetEntity_exifInfo_exifTextSearchableColumn",
"AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId",
"AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags",
"AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects",
"AssetEntity__AssetEntity_smartInfo"."clipEmbedding" AS "AssetEntity__AssetEntity_smartInfo_clipEmbedding",
"AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id",
"AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type",
"AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name",
@@ -230,7 +226,6 @@ SELECT
"AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
"AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
"AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
"AssetEntity__AssetEntity_faces"."embedding" AS "AssetEntity__AssetEntity_faces_embedding",
"AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth",
"AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight",
"AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1",
@@ -439,7 +434,6 @@ FROM
"AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
"AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
"AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
"AssetEntity__AssetEntity_faces"."embedding" AS "AssetEntity__AssetEntity_faces_embedding",
"AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth",
"AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight",
"AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1",
@@ -612,3 +606,73 @@ ORDER BY
"AssetEntity"."createdAt" ASC
LIMIT
11
-- AssetRepository.getAssetIdByCity
WITH
"cities" AS (
SELECT
city
FROM
"exif" "e"
GROUP BY
city
HAVING
count(city) >= $1
ORDER BY
random() ASC
LIMIT
12
)
SELECT DISTINCT
ON (c.city) "asset"."id" AS "data",
c.city AS "value"
FROM
"assets" "asset"
INNER JOIN "exif" "e" ON "asset"."id" = e."assetId"
INNER JOIN "cities" "c" ON c.city = "e"."city"
WHERE
(
"asset"."isVisible" = true
AND "asset"."fileCreatedAt" < NOW()
AND "asset"."type" = $2
AND "asset"."ownerId" IN ($3)
AND "asset"."isArchived" = $4
)
AND ("asset"."deletedAt" IS NULL)
LIMIT
12
-- AssetRepository.getAssetIdByTag
WITH
"random_tags" AS (
SELECT
unnest(tags) AS "tag"
FROM
"smart_info" "si"
GROUP BY
tag
HAVING
count(*) >= $1
ORDER BY
random() ASC
LIMIT
12
)
SELECT DISTINCT
ON (unnest("si"."tags")) "asset"."id" AS "data",
unnest("si"."tags") AS "value"
FROM
"assets" "asset"
INNER JOIN "smart_info" "si" ON "asset"."id" = si."assetId"
INNER JOIN "random_tags" "t" ON "si"."tags" @> ARRAY[t.tag]
WHERE
(
"asset"."isVisible" = true
AND "asset"."fileCreatedAt" < NOW()
AND "asset"."type" = $2
AND "asset"."ownerId" IN ($3)
AND "asset"."isArchived" = $4
)
AND ("asset"."deletedAt" IS NULL)
LIMIT
12
+1 -9
View File
@@ -12,7 +12,6 @@ SELECT
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
@@ -138,7 +137,6 @@ SELECT
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
@@ -169,7 +167,6 @@ FROM
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
@@ -205,7 +202,6 @@ FROM
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
@@ -351,7 +347,6 @@ FROM
"AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
"AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
"AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
"AssetEntity__AssetEntity_faces"."embedding" AS "AssetEntity__AssetEntity_faces_embedding",
"AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth",
"AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight",
"AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1",
@@ -393,8 +388,7 @@ FROM
"AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription",
"AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace",
"AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample",
"AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps",
"AssetEntity__AssetEntity_exifInfo"."exifTextSearchableColumn" AS "AssetEntity__AssetEntity_exifInfo_exifTextSearchableColumn"
"AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps"
FROM
"assets" "AssetEntity"
LEFT JOIN "asset_faces" "AssetEntity__AssetEntity_faces" ON "AssetEntity__AssetEntity_faces"."assetId" = "AssetEntity"."id"
@@ -421,7 +415,6 @@ SELECT
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
@@ -473,7 +466,6 @@ SELECT
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
@@ -77,7 +77,6 @@ FROM
"9b1d35b344d838023994a3233afd6ffe098be6d8"."colorspace" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_colorspace",
"9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample",
"9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps",
"9b1d35b344d838023994a3233afd6ffe098be6d8"."exifTextSearchableColumn" AS "e18de9deffa83f81ac3c43b5e8c2f08dba727bf8",
"SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id",
"SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId",
"SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName",
@@ -143,7 +142,6 @@ FROM
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."colorspace" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_colorspace",
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample",
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps",
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."exifTextSearchableColumn" AS "96535c8046de591cca9b8c5825e6c5db502b0e6a",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor",
@@ -0,0 +1,111 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SmartInfoRepository.searchCLIP
START TRANSACTION
SET
LOCAL vectors.k = '100'
SELECT
"a"."id" AS "a_id",
"a"."deviceAssetId" AS "a_deviceAssetId",
"a"."ownerId" AS "a_ownerId",
"a"."libraryId" AS "a_libraryId",
"a"."deviceId" AS "a_deviceId",
"a"."type" AS "a_type",
"a"."originalPath" AS "a_originalPath",
"a"."resizePath" AS "a_resizePath",
"a"."webpPath" AS "a_webpPath",
"a"."thumbhash" AS "a_thumbhash",
"a"."encodedVideoPath" AS "a_encodedVideoPath",
"a"."createdAt" AS "a_createdAt",
"a"."updatedAt" AS "a_updatedAt",
"a"."deletedAt" AS "a_deletedAt",
"a"."fileCreatedAt" AS "a_fileCreatedAt",
"a"."localDateTime" AS "a_localDateTime",
"a"."fileModifiedAt" AS "a_fileModifiedAt",
"a"."isFavorite" AS "a_isFavorite",
"a"."isArchived" AS "a_isArchived",
"a"."isExternal" AS "a_isExternal",
"a"."isReadOnly" AS "a_isReadOnly",
"a"."isOffline" AS "a_isOffline",
"a"."checksum" AS "a_checksum",
"a"."duration" AS "a_duration",
"a"."isVisible" AS "a_isVisible",
"a"."livePhotoVideoId" AS "a_livePhotoVideoId",
"a"."originalFileName" AS "a_originalFileName",
"a"."sidecarPath" AS "a_sidecarPath",
"a"."stackParentId" AS "a_stackParentId",
"e"."assetId" AS "e_assetId",
"e"."description" AS "e_description",
"e"."exifImageWidth" AS "e_exifImageWidth",
"e"."exifImageHeight" AS "e_exifImageHeight",
"e"."fileSizeInByte" AS "e_fileSizeInByte",
"e"."orientation" AS "e_orientation",
"e"."dateTimeOriginal" AS "e_dateTimeOriginal",
"e"."modifyDate" AS "e_modifyDate",
"e"."timeZone" AS "e_timeZone",
"e"."latitude" AS "e_latitude",
"e"."longitude" AS "e_longitude",
"e"."projectionType" AS "e_projectionType",
"e"."city" AS "e_city",
"e"."livePhotoCID" AS "e_livePhotoCID",
"e"."state" AS "e_state",
"e"."country" AS "e_country",
"e"."make" AS "e_make",
"e"."model" AS "e_model",
"e"."lensModel" AS "e_lensModel",
"e"."fNumber" AS "e_fNumber",
"e"."focalLength" AS "e_focalLength",
"e"."iso" AS "e_iso",
"e"."exposureTime" AS "e_exposureTime",
"e"."profileDescription" AS "e_profileDescription",
"e"."colorspace" AS "e_colorspace",
"e"."bitsPerSample" AS "e_bitsPerSample",
"e"."fps" AS "e_fps"
FROM
"assets" "a"
INNER JOIN "smart_search" "s" ON "s"."assetId" = "a"."id"
LEFT JOIN "exif" "e" ON "e"."assetId" = "a"."id"
WHERE
("a"."ownerId" = $1)
AND ("a"."deletedAt" IS NULL)
ORDER BY
"s"."embedding" <= > $2 ASC
LIMIT
100
COMMIT
-- SmartInfoRepository.searchFaces
START TRANSACTION
SET
LOCAL vectors.k = '100'
WITH
"cte" AS (
SELECT
"faces"."id" AS "faces_id",
"faces"."assetId" AS "faces_assetId",
"faces"."personId" AS "faces_personId",
"faces"."imageWidth" AS "faces_imageWidth",
"faces"."imageHeight" AS "faces_imageHeight",
"faces"."boundingBoxX1" AS "faces_boundingBoxX1",
"faces"."boundingBoxY1" AS "faces_boundingBoxY1",
"faces"."boundingBoxX2" AS "faces_boundingBoxX2",
"faces"."boundingBoxY2" AS "faces_boundingBoxY2",
1 + ("faces"."embedding" <= > $1) AS "distance"
FROM
"asset_faces" "faces"
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $2
ORDER BY
"faces"."embedding" <= > $3 ASC
LIMIT
100
)
SELECT
res.*
FROM
"cte" "res"
WHERE
res.distance <= $4
COMMIT
@@ -1,14 +0,0 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const albumSchemaVersion = 2;
export const albumSchema: CollectionCreateSchema = {
name: `albums-v${albumSchemaVersion}`,
fields: [
{ name: 'ownerId', type: 'string', facet: false },
{ name: 'albumName', type: 'string', facet: false, sort: true },
{ name: 'description', type: 'string', facet: false },
{ name: 'createdAt', type: 'string', facet: false, sort: true },
{ name: 'updatedAt', type: 'string', facet: false, sort: true },
],
default_sorting_field: 'createdAt',
};
@@ -1,42 +0,0 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const assetSchemaVersion = 10;
export const assetSchema: CollectionCreateSchema = {
name: `assets-v${assetSchemaVersion}`,
fields: [
// asset
{ name: 'ownerId', type: 'string', facet: false },
{ name: 'type', type: 'string', facet: true },
{ name: 'originalPath', type: 'string', facet: false },
{ name: 'createdAt', type: 'string', facet: false, sort: true },
{ name: 'updatedAt', type: 'string', facet: false, sort: true },
{ name: 'fileCreatedAt', type: 'string', facet: false, sort: true },
{ name: 'fileModifiedAt', type: 'string', facet: false, sort: true },
{ name: 'isFavorite', type: 'bool', facet: true },
{ name: 'isArchived', type: 'bool', facet: true },
{ name: 'originalFileName', type: 'string', facet: false, optional: true },
// exif
{ name: 'exifInfo.city', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.country', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.state', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.description', type: 'string', facet: false, optional: true },
{ name: 'exifInfo.make', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.model', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.orientation', type: 'string', optional: true },
{ name: 'exifInfo.projectionType', type: 'string', facet: true, optional: true },
// smart info
{ name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
{ name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
{ name: 'smartInfo.clipEmbedding', type: 'float[]', facet: false, optional: true, num_dim: 512 },
// computed
{ name: 'geo', type: 'geopoint', facet: false, optional: true },
{ name: 'motion', type: 'bool', facet: true },
{ name: 'people', type: 'string[]', facet: true, optional: true },
],
token_separators: ['.', '-', '_'],
enable_nested_fields: true,
default_sorting_field: 'fileCreatedAt',
};
@@ -1,12 +0,0 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const faceSchemaVersion = 1;
export const faceSchema: CollectionCreateSchema = {
name: `faces-v${faceSchemaVersion}`,
fields: [
{ name: 'ownerId', type: 'string', facet: false },
{ name: 'assetId', type: 'string', facet: false },
{ name: 'personId', type: 'string', facet: false },
{ name: 'embedding', type: 'float[]', facet: false, num_dim: 512 },
],
};
@@ -1,3 +0,0 @@
export * from './album.schema';
export * from './asset.schema';
export * from './face.schema';
-3
View File
@@ -1,3 +0,0 @@
export enum DatabaseLock {
GeodataImport = 100,
}
@@ -1,15 +0,0 @@
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
/**
* Allows optional values unlike the regular Between and uses MoreThanOrEqual
* or LessThanOrEqual when only one parameter is specified.
*/
export default function OptionalBetween<T>(from?: T, to?: T) {
if (from && to) {
return Between(from, to);
} else if (from) {
return MoreThanOrEqual(from);
} else if (to) {
return LessThanOrEqual(to);
}
}
-20
View File
@@ -1,20 +0,0 @@
import { Paginated, PaginationOptions } from '@app/domain';
import { FindOneOptions, ObjectLiteral, Repository } from 'typeorm';
export async function paginate<Entity extends ObjectLiteral>(
repository: Repository<Entity>,
paginationOptions: PaginationOptions,
searchOptions?: FindOneOptions<Entity>,
): Paginated<Entity> {
const items = await repository.find({
...searchOptions,
// Take one more item to check if there's a next page
take: paginationOptions.take + 1,
skip: paginationOptions.skip,
});
const hasNextPage = items.length > paginationOptions.take;
items.splice(paginationOptions.take);
return { items, hasNextPage };
}