forked from Cutlery/immich
search, kysely extension
This commit is contained in:
parent
77da10e3ab
commit
da34bd714e
2288
server/package-lock.json
generated
2288
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.99.0",
|
"version": "1.99.0",
|
||||||
"version": "1.99.0",
|
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -30,10 +29,6 @@
|
|||||||
"typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js",
|
"typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js",
|
||||||
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
|
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
|
||||||
"typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'",
|
"typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'",
|
||||||
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/database.config.js",
|
|
||||||
"typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js",
|
|
||||||
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
|
|
||||||
"typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'",
|
|
||||||
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
|
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
|
||||||
"sql:generate": "node ./dist/utils/sql.js",
|
"sql:generate": "node ./dist/utils/sql.js",
|
||||||
"prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma"
|
"prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma"
|
||||||
@ -77,6 +72,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"joi": "^17.10.0",
|
"joi": "^17.10.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
"kysely": "^0.27.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"mnemonist": "^0.39.8",
|
"mnemonist": "^0.39.8",
|
||||||
@ -85,6 +81,7 @@
|
|||||||
"openid-client": "^5.4.3",
|
"openid-client": "^5.4.3",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"picomatch": "^4.0.0",
|
"picomatch": "^4.0.0",
|
||||||
|
"prisma-extension-kysely": "^2.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
@ -128,6 +125,7 @@
|
|||||||
"prettier": "^3.0.2",
|
"prettier": "^3.0.2",
|
||||||
"prettier-plugin-organize-imports": "^3.2.3",
|
"prettier-plugin-organize-imports": "^3.2.3",
|
||||||
"prisma": "^5.11.0",
|
"prisma": "^5.11.0",
|
||||||
|
"prisma-kysely": "^1.8.0",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"sql-formatter": "^15.0.0",
|
"sql-formatter": "^15.0.0",
|
||||||
|
@ -123,6 +123,7 @@ import { TrashService } from 'src/services/trash.service';
|
|||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
import { otelConfig } from 'src/utils/instrumentation';
|
import { otelConfig } from 'src/utils/instrumentation';
|
||||||
import { ImmichLogger } from 'src/utils/logger';
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
|
import { PrismaRepository } from './repositories/prisma.repository';
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
ResetAdminPasswordCommand,
|
ResetAdminPasswordCommand,
|
||||||
@ -211,6 +212,7 @@ const repositories: Provider[] = [
|
|||||||
{ provide: IMoveRepository, useClass: MoveRepository },
|
{ provide: IMoveRepository, useClass: MoveRepository },
|
||||||
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
||||||
{ provide: IPersonRepository, useClass: PersonRepository },
|
{ provide: IPersonRepository, useClass: PersonRepository },
|
||||||
|
{ provide: PrismaRepository, useClass: PrismaRepository },
|
||||||
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
||||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||||
{ provide: ISearchRepository, useClass: SearchRepository },
|
{ provide: ISearchRepository, useClass: SearchRepository },
|
||||||
|
@ -175,7 +175,7 @@ export type SmartSearchOptions = SearchDateOptions &
|
|||||||
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
||||||
hasPerson?: boolean;
|
hasPerson?: boolean;
|
||||||
numResults: number;
|
numResults: number;
|
||||||
maxDistance?: number;
|
maxDistance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FaceSearchResult {
|
export interface FaceSearchResult {
|
||||||
@ -188,7 +188,7 @@ export interface ISearchRepository {
|
|||||||
searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
|
searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
|
||||||
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
||||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||||
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
upsert(assetId: string, embedding: number[]): Promise<void>;
|
||||||
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
||||||
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]>;
|
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]>;
|
||||||
deleteAllSearchEmbeddings(): Promise<void>;
|
deleteAllSearchEmbeddings(): Promise<void>;
|
||||||
|
292
server/src/prisma/generated/types.ts
Normal file
292
server/src/prisma/generated/types.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import type { ColumnType } from "kysely";
|
||||||
|
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||||
|
? ColumnType<S, I | undefined, U>
|
||||||
|
: ColumnType<T, T | undefined, T>;
|
||||||
|
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||||
|
|
||||||
|
export type Activity = {
|
||||||
|
id: Generated<string>;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
albumId: string;
|
||||||
|
userId: string;
|
||||||
|
assetId: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
isLiked: Generated<boolean>;
|
||||||
|
};
|
||||||
|
export type Albums = {
|
||||||
|
id: Generated<string>;
|
||||||
|
ownerId: string;
|
||||||
|
albumName: Generated<string>;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
albumThumbnailAssetId: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
description: Generated<string>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
isActivityEnabled: Generated<boolean>;
|
||||||
|
order: Generated<string>;
|
||||||
|
};
|
||||||
|
export type AlbumsAssetsAssets = {
|
||||||
|
albumsId: string;
|
||||||
|
assetsId: string;
|
||||||
|
};
|
||||||
|
export type AlbumsSharedUsersUsers = {
|
||||||
|
albumsId: string;
|
||||||
|
usersId: string;
|
||||||
|
};
|
||||||
|
export type ApiKeys = {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
id: Generated<string>;
|
||||||
|
};
|
||||||
|
export type AssetFaces = {
|
||||||
|
assetId: string;
|
||||||
|
personId: string | null;
|
||||||
|
imageWidth: Generated<number>;
|
||||||
|
imageHeight: Generated<number>;
|
||||||
|
boundingBoxX1: Generated<number>;
|
||||||
|
boundingBoxY1: Generated<number>;
|
||||||
|
boundingBoxX2: Generated<number>;
|
||||||
|
boundingBoxY2: Generated<number>;
|
||||||
|
id: Generated<string>;
|
||||||
|
};
|
||||||
|
export type AssetJobStatus = {
|
||||||
|
assetId: string;
|
||||||
|
facesRecognizedAt: Timestamp | null;
|
||||||
|
metadataExtractedAt: Timestamp | null;
|
||||||
|
};
|
||||||
|
export type Assets = {
|
||||||
|
id: Generated<string>;
|
||||||
|
deviceAssetId: string;
|
||||||
|
ownerId: string;
|
||||||
|
deviceId: string;
|
||||||
|
type: string;
|
||||||
|
originalPath: string;
|
||||||
|
resizePath: string | null;
|
||||||
|
fileCreatedAt: Timestamp;
|
||||||
|
fileModifiedAt: Timestamp;
|
||||||
|
isFavorite: Generated<boolean>;
|
||||||
|
duration: string | null;
|
||||||
|
webpPath: Generated<string | null>;
|
||||||
|
encodedVideoPath: Generated<string | null>;
|
||||||
|
checksum: Buffer;
|
||||||
|
isVisible: Generated<boolean>;
|
||||||
|
livePhotoVideoId: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
isArchived: Generated<boolean>;
|
||||||
|
originalFileName: string;
|
||||||
|
sidecarPath: string | null;
|
||||||
|
isReadOnly: Generated<boolean>;
|
||||||
|
thumbhash: Buffer | null;
|
||||||
|
isOffline: Generated<boolean>;
|
||||||
|
libraryId: string;
|
||||||
|
isExternal: Generated<boolean>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
localDateTime: Timestamp;
|
||||||
|
stackId: string | null;
|
||||||
|
truncatedDate: Generated<Timestamp>;
|
||||||
|
};
|
||||||
|
export type AssetStack = {
|
||||||
|
id: Generated<string>;
|
||||||
|
primaryAssetId: string;
|
||||||
|
};
|
||||||
|
export type Audit = {
|
||||||
|
id: Generated<number>;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
ownerId: string;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
};
|
||||||
|
export type Exif = {
|
||||||
|
assetId: string;
|
||||||
|
make: string | null;
|
||||||
|
model: string | null;
|
||||||
|
exifImageWidth: number | null;
|
||||||
|
exifImageHeight: number | null;
|
||||||
|
fileSizeInByte: string | null;
|
||||||
|
orientation: string | null;
|
||||||
|
dateTimeOriginal: Timestamp | null;
|
||||||
|
modifyDate: Timestamp | null;
|
||||||
|
lensModel: string | null;
|
||||||
|
fNumber: number | null;
|
||||||
|
focalLength: number | null;
|
||||||
|
iso: number | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
country: string | null;
|
||||||
|
description: Generated<string>;
|
||||||
|
fps: number | null;
|
||||||
|
exposureTime: string | null;
|
||||||
|
livePhotoCID: string | null;
|
||||||
|
timeZone: string | null;
|
||||||
|
projectionType: string | null;
|
||||||
|
profileDescription: string | null;
|
||||||
|
colorspace: string | null;
|
||||||
|
bitsPerSample: number | null;
|
||||||
|
autoStackId: string | null;
|
||||||
|
};
|
||||||
|
export type GeodataPlaces = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
countryCode: string;
|
||||||
|
admin1Code: string | null;
|
||||||
|
admin2Code: string | null;
|
||||||
|
modificationDate: Timestamp;
|
||||||
|
admin1Name: string | null;
|
||||||
|
admin2Name: string | null;
|
||||||
|
alternateNames: string | null;
|
||||||
|
};
|
||||||
|
export type Libraries = {
|
||||||
|
id: Generated<string>;
|
||||||
|
name: string;
|
||||||
|
ownerId: string;
|
||||||
|
type: string;
|
||||||
|
importPaths: string[];
|
||||||
|
exclusionPatterns: string[];
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
refreshedAt: Timestamp | null;
|
||||||
|
isVisible: Generated<boolean>;
|
||||||
|
};
|
||||||
|
export type MoveHistory = {
|
||||||
|
id: Generated<string>;
|
||||||
|
entityId: string;
|
||||||
|
pathType: string;
|
||||||
|
oldPath: string;
|
||||||
|
newPath: string;
|
||||||
|
};
|
||||||
|
export type Partners = {
|
||||||
|
sharedById: string;
|
||||||
|
sharedWithId: string;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
inTimeline: Generated<boolean>;
|
||||||
|
};
|
||||||
|
export type Person = {
|
||||||
|
id: Generated<string>;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
ownerId: string;
|
||||||
|
name: Generated<string>;
|
||||||
|
thumbnailPath: Generated<string>;
|
||||||
|
isHidden: Generated<boolean>;
|
||||||
|
birthDate: Timestamp | null;
|
||||||
|
faceAssetId: string | null;
|
||||||
|
};
|
||||||
|
export type SharedLinkAsset = {
|
||||||
|
assetsId: string;
|
||||||
|
sharedLinksId: string;
|
||||||
|
};
|
||||||
|
export type SharedLinks = {
|
||||||
|
id: Generated<string>;
|
||||||
|
description: string | null;
|
||||||
|
userId: string;
|
||||||
|
key: Buffer;
|
||||||
|
type: string;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
expiresAt: Timestamp | null;
|
||||||
|
allowUpload: Generated<boolean>;
|
||||||
|
albumId: string | null;
|
||||||
|
allowDownload: Generated<boolean>;
|
||||||
|
showExif: Generated<boolean>;
|
||||||
|
password: string | null;
|
||||||
|
};
|
||||||
|
export type SmartInfo = {
|
||||||
|
assetId: string;
|
||||||
|
tags: string[];
|
||||||
|
objects: string[];
|
||||||
|
};
|
||||||
|
export type SmartSearch = {
|
||||||
|
assetId: string;
|
||||||
|
};
|
||||||
|
export type SocketIoAttachments = {
|
||||||
|
id: Generated<string>;
|
||||||
|
created_at: Generated<Timestamp | null>;
|
||||||
|
payload: Buffer | null;
|
||||||
|
};
|
||||||
|
export type SystemConfig = {
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
};
|
||||||
|
export type SystemMetadata = {
|
||||||
|
key: string;
|
||||||
|
value: Generated<unknown>;
|
||||||
|
};
|
||||||
|
export type TagAsset = {
|
||||||
|
assetsId: string;
|
||||||
|
tagsId: string;
|
||||||
|
};
|
||||||
|
export type Tags = {
|
||||||
|
id: Generated<string>;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
userId: string;
|
||||||
|
renameTagId: string | null;
|
||||||
|
};
|
||||||
|
export type Users = {
|
||||||
|
id: Generated<string>;
|
||||||
|
email: string;
|
||||||
|
password: Generated<string>;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
profileImagePath: Generated<string>;
|
||||||
|
isAdmin: Generated<boolean>;
|
||||||
|
shouldChangePassword: Generated<boolean>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
oauthId: Generated<string>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
storageLabel: string | null;
|
||||||
|
memoriesEnabled: Generated<boolean>;
|
||||||
|
name: Generated<string>;
|
||||||
|
avatarColor: string | null;
|
||||||
|
quotaSizeInBytes: string | null;
|
||||||
|
quotaUsageInBytes: Generated<string>;
|
||||||
|
status: Generated<string>;
|
||||||
|
};
|
||||||
|
export type UserToken = {
|
||||||
|
id: Generated<string>;
|
||||||
|
token: string;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
userId: string;
|
||||||
|
deviceType: Generated<string>;
|
||||||
|
deviceOS: Generated<string>;
|
||||||
|
};
|
||||||
|
export type DB = {
|
||||||
|
activity: Activity;
|
||||||
|
albums: Albums;
|
||||||
|
albums_assets_assets: AlbumsAssetsAssets;
|
||||||
|
albums_shared_users_users: AlbumsSharedUsersUsers;
|
||||||
|
api_keys: ApiKeys;
|
||||||
|
asset_faces: AssetFaces;
|
||||||
|
asset_job_status: AssetJobStatus;
|
||||||
|
asset_stack: AssetStack;
|
||||||
|
assets: Assets;
|
||||||
|
audit: Audit;
|
||||||
|
exif: Exif;
|
||||||
|
geodata_places: GeodataPlaces;
|
||||||
|
libraries: Libraries;
|
||||||
|
move_history: MoveHistory;
|
||||||
|
partners: Partners;
|
||||||
|
person: Person;
|
||||||
|
shared_link__asset: SharedLinkAsset;
|
||||||
|
shared_links: SharedLinks;
|
||||||
|
smart_info: SmartInfo;
|
||||||
|
smart_search: SmartSearch;
|
||||||
|
socket_io_attachments: SocketIoAttachments;
|
||||||
|
system_config: SystemConfig;
|
||||||
|
system_metadata: SystemMetadata;
|
||||||
|
tag_asset: TagAsset;
|
||||||
|
tags: Tags;
|
||||||
|
user_token: UserToken;
|
||||||
|
users: Users;
|
||||||
|
};
|
16
server/src/prisma/kysely.ts
Normal file
16
server/src/prisma/kysely.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { DeduplicateJoinsPlugin, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
|
||||||
|
import kyselyExt from 'prisma-extension-kysely';
|
||||||
|
import type { DB } from './generated/types';
|
||||||
|
|
||||||
|
export const kyselyExtension = kyselyExt({
|
||||||
|
kysely: (driver) =>
|
||||||
|
new Kysely<DB>({
|
||||||
|
dialect: {
|
||||||
|
createDriver: () => driver,
|
||||||
|
createAdapter: () => new PostgresAdapter(),
|
||||||
|
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||||
|
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||||
|
},
|
||||||
|
plugins: [new DeduplicateJoinsPlugin()],
|
||||||
|
}),
|
||||||
|
});
|
@ -3,6 +3,10 @@ generator client {
|
|||||||
previewFeatures = ["postgresqlExtensions", "relationJoins"]
|
previewFeatures = ["postgresqlExtensions", "relationJoins"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generator kysely {
|
||||||
|
provider = "prisma-kysely"
|
||||||
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DB_URL")
|
url = env("DB_URL")
|
||||||
|
@ -2,14 +2,26 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { findNonDeletedExtension } from '../prisma/find-non-deleted';
|
import { findNonDeletedExtension } from '../prisma/find-non-deleted';
|
||||||
import { metricsExtension } from '../prisma/metrics';
|
import { metricsExtension } from '../prisma/metrics';
|
||||||
|
import { kyselyExtension } from 'src/prisma/kysely';
|
||||||
|
|
||||||
|
function extendClient(base: PrismaClient) {
|
||||||
|
return base.$extends(metricsExtension).$extends(findNonDeletedExtension).$extends(kyselyExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UntypedExtendedClient extends PrismaClient {
|
||||||
|
constructor(options?: ConstructorParameters<typeof PrismaClient>[0]) {
|
||||||
|
super(options);
|
||||||
|
|
||||||
|
return extendClient(this) as this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExtendedPrismaClient = UntypedExtendedClient as unknown as new (
|
||||||
|
options?: ConstructorParameters<typeof PrismaClient>[0],
|
||||||
|
) => ReturnType<typeof extendClient>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaRepository extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaRepository extends ExtendedPrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
return this.$extends(metricsExtension).$extends(findNonDeletedExtension) as this;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
@ -1,57 +1,35 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { ExpressionBuilder, Kysely, OrderByDirectionExpression, sql } from 'kysely';
|
||||||
import { vectorExt } from 'src/database.config';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
|
import _ from 'lodash';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||||
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
||||||
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
|
||||||
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
|
||||||
import { DatabaseExtension } from 'src/interfaces/database.interface';
|
|
||||||
import {
|
import {
|
||||||
|
AssetSearchBuilderOptions,
|
||||||
AssetSearchOptions,
|
AssetSearchOptions,
|
||||||
Embedding,
|
|
||||||
FaceEmbeddingSearch,
|
FaceEmbeddingSearch,
|
||||||
FaceSearchResult,
|
FaceSearchResult,
|
||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
SearchPaginationOptions,
|
SearchPaginationOptions,
|
||||||
SmartSearchOptions,
|
SmartSearchOptions,
|
||||||
} from 'src/interfaces/search.interface';
|
} from 'src/interfaces/search.interface';
|
||||||
import { asVector, searchAssetBuilder } from 'src/utils/database';
|
import { DB } from 'src/prisma/generated/types';
|
||||||
|
import { asVector } from 'src/utils/database';
|
||||||
import { Instrumentation } from 'src/utils/instrumentation';
|
import { Instrumentation } from 'src/utils/instrumentation';
|
||||||
import { ImmichLogger } from 'src/utils/logger';
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
import { getCLIPModelInfo } from 'src/utils/misc';
|
import { getCLIPModelInfo } from 'src/utils/misc';
|
||||||
import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination';
|
import { Paginated } from 'src/utils/pagination';
|
||||||
import { isValidInteger } from 'src/validation';
|
import { isValidInteger } from 'src/validation';
|
||||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
import { PrismaRepository } from './prisma.repository';
|
||||||
|
|
||||||
@Instrumentation()
|
@Instrumentation()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchRepository implements ISearchRepository {
|
export class SearchRepository implements ISearchRepository {
|
||||||
private logger = new ImmichLogger(SearchRepository.name);
|
private logger = new ImmichLogger(SearchRepository.name);
|
||||||
private faceColumns: string[];
|
|
||||||
private assetsByCityQuery: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(@Inject(PrismaRepository) private prismaRepository: PrismaRepository) {}
|
||||||
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
|
|
||||||
@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)
|
|
||||||
.ownColumns.map((column) => column.propertyName)
|
|
||||||
.filter((propertyName) => propertyName !== 'embedding');
|
|
||||||
this.assetsByCityQuery =
|
|
||||||
assetsByCityCte +
|
|
||||||
this.assetRepository
|
|
||||||
.createQueryBuilder('asset')
|
|
||||||
.innerJoinAndSelect('asset.exifInfo', 'exif')
|
|
||||||
.withDeleted()
|
|
||||||
.getQuery() +
|
|
||||||
' INNER JOIN cte ON asset.id = cte."assetId"';
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(modelName: string): Promise<void> {
|
async init(modelName: string): Promise<void> {
|
||||||
const { dimSize } = getCLIPModelInfo(modelName);
|
const { dimSize } = getCLIPModelInfo(modelName);
|
||||||
@ -78,23 +56,16 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
|
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
|
||||||
let builder = this.assetRepository.createQueryBuilder('asset');
|
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirectionExpression;
|
||||||
builder = searchAssetBuilder(builder, options);
|
const builder = this.searchAssetBuilder(options)
|
||||||
|
.orderBy('assets.fileCreatedAt', orderDirection)
|
||||||
|
.limit(pagination.size + 1)
|
||||||
|
.offset((pagination.page - 1) * pagination.size);
|
||||||
|
|
||||||
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
|
const items = (await builder.execute()) as any as AssetEntity[];
|
||||||
return paginatedBuilder<AssetEntity>(builder, {
|
const hasNextPage = items.length > pagination.size;
|
||||||
mode: PaginationMode.SKIP_TAKE,
|
items.splice(pagination.size);
|
||||||
skip: (pagination.page - 1) * pagination.size,
|
return { items, hasNextPage };
|
||||||
take: pagination.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createPersonFilter(builder: SelectQueryBuilder<AssetFaceEntity>, personIds: string[]) {
|
|
||||||
return builder
|
|
||||||
.select(`${builder.alias}."assetId"`)
|
|
||||||
.where(`${builder.alias}."personId" IN (:...personIds)`, { personIds })
|
|
||||||
.groupBy(`${builder.alias}."assetId"`)
|
|
||||||
.having(`COUNT(DISTINCT ${builder.alias}."personId") = :personCount`, { personCount: personIds.length });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
@ -114,35 +85,25 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
pagination: SearchPaginationOptions,
|
pagination: SearchPaginationOptions,
|
||||||
{ embedding, userIds, personIds, ...options }: SmartSearchOptions,
|
{ embedding, userIds, personIds, ...options }: SmartSearchOptions,
|
||||||
): Paginated<AssetEntity> {
|
): Paginated<AssetEntity> {
|
||||||
let results: PaginationResult<AssetEntity> = { items: [], hasNextPage: false };
|
if (!isValidInteger(pagination.size, { min: 1 })) {
|
||||||
|
throw new Error(`Invalid value for 'size': ${pagination.size}`);
|
||||||
|
}
|
||||||
|
|
||||||
await this.assetRepository.manager.transaction(async (manager) => {
|
let items: AssetEntity[] = [];
|
||||||
let builder = manager.createQueryBuilder(AssetEntity, 'asset');
|
await this.prismaRepository.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRawUnsafe(`SET LOCAL vectors.hnsw_ef_search = ${pagination.size + 1}`);
|
||||||
|
let builder = this.searchAssetBuilder(options, tx.$kysely)
|
||||||
|
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
|
||||||
|
.orderBy(sql`smart_search.embedding <=> ${asVector(embedding)}::vector`)
|
||||||
|
.limit(pagination.size + 1)
|
||||||
|
.offset((pagination.page - 1) * pagination.size);
|
||||||
|
|
||||||
if (personIds?.length) {
|
items = (await builder.execute()) as any as AssetEntity[];
|
||||||
const assetFaceBuilder = manager.createQueryBuilder(AssetFaceEntity, 'asset_face');
|
|
||||||
const cte = this.createPersonFilter(assetFaceBuilder, personIds);
|
|
||||||
builder
|
|
||||||
.addCommonTableExpression(cte, 'asset_face_ids')
|
|
||||||
.innerJoin('asset_face_ids', 'a', 'a."assetId" = asset.id');
|
|
||||||
}
|
|
||||||
|
|
||||||
builder = searchAssetBuilder(builder, options);
|
|
||||||
builder
|
|
||||||
.innerJoin('asset.smartSearch', 'search')
|
|
||||||
.andWhere('asset.ownerId IN (:...userIds )')
|
|
||||||
.orderBy('search.embedding <=> :embedding')
|
|
||||||
.setParameters({ userIds, embedding: asVector(embedding) });
|
|
||||||
|
|
||||||
await manager.query(this.getRuntimeConfig(pagination.size));
|
|
||||||
results = await paginatedBuilder<AssetEntity>(builder, {
|
|
||||||
mode: PaginationMode.LIMIT_OFFSET,
|
|
||||||
skip: (pagination.page - 1) * pagination.size,
|
|
||||||
take: pagination.size,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return results;
|
const hasNextPage = items.length > pagination.size;
|
||||||
|
items.splice(pagination.size);
|
||||||
|
return { items, hasNextPage };
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
@ -155,7 +116,7 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
async searchFaces({
|
searchFaces({
|
||||||
userIds,
|
userIds,
|
||||||
embedding,
|
embedding,
|
||||||
numResults,
|
numResults,
|
||||||
@ -168,99 +129,91 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
|
|
||||||
// setting this too low messes with prefilter recall
|
// setting this too low messes with prefilter recall
|
||||||
numResults = Math.max(numResults, 64);
|
numResults = Math.max(numResults, 64);
|
||||||
|
const vector = asVector(embedding);
|
||||||
let results: Array<AssetFaceEntity & { distance: number }> = [];
|
return this.prismaRepository.$transaction(async (tx) => {
|
||||||
await this.assetRepository.manager.transaction(async (manager) => {
|
await tx.$queryRawUnsafe(`SET LOCAL vectors.hnsw_ef_search = ${numResults}`);
|
||||||
const cte = manager
|
return tx.$kysely
|
||||||
.createQueryBuilder(AssetFaceEntity, 'faces')
|
.with('cte', (qb) =>
|
||||||
.select('faces.embedding <=> :embedding', 'distance')
|
qb
|
||||||
.innerJoin('faces.asset', 'asset')
|
.selectFrom('asset_faces')
|
||||||
.where('asset.ownerId IN (:...userIds )')
|
.select([
|
||||||
.orderBy('faces.embedding <=> :embedding')
|
(eb) => eb.fn.toJson(sql`asset_faces.*`).as('face'),
|
||||||
.setParameters({ userIds, embedding: asVector(embedding) });
|
sql<number>`asset_faces.embedding <=> ${vector}::vector`.as('distance'),
|
||||||
|
])
|
||||||
cte.limit(numResults);
|
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
|
||||||
|
.where('assets.ownerId', '=', sql<string>`ANY(ARRAY[${userIds}]::uuid[])`)
|
||||||
if (hasPerson) {
|
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
|
||||||
cte.andWhere('faces."personId" IS NOT NULL');
|
.orderBy(sql`asset_faces.embedding <=> ${vector}::vector`)
|
||||||
}
|
.limit(numResults),
|
||||||
|
)
|
||||||
for (const col of this.faceColumns) {
|
.selectFrom('cte')
|
||||||
cte.addSelect(`faces.${col}`, col);
|
.where('cte.distance', '<=', maxDistance)
|
||||||
}
|
.execute() as any as Array<{ face: AssetFaceEntity; distance: number }>;
|
||||||
|
|
||||||
await manager.query(this.getRuntimeConfig(numResults));
|
|
||||||
results = await manager
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select('res.*')
|
|
||||||
.addCommonTableExpression(cte, 'cte')
|
|
||||||
.from('cte', 'res')
|
|
||||||
.where('res.distance <= :maxDistance', { maxDistance })
|
|
||||||
.orderBy('res.distance')
|
|
||||||
.getRawMany();
|
|
||||||
});
|
});
|
||||||
return results.map((row) => ({
|
|
||||||
face: this.assetFaceRepository.create(row),
|
|
||||||
distance: row.distance,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
|
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
|
||||||
return await this.geodataPlacesRepository
|
const contains = '%>>' as any as 'ilike';
|
||||||
.createQueryBuilder('geoplaces')
|
return this.prismaRepository.$kysely
|
||||||
.where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
|
.selectFrom('geodata_places')
|
||||||
.orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
|
.selectAll()
|
||||||
.orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
|
.where((eb) =>
|
||||||
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
|
eb.or([
|
||||||
.orderBy(
|
eb(eb.fn('f_unaccent', ['name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||||
`
|
eb(eb.fn('f_unaccent', ['admin2Name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||||
COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
|
eb(eb.fn('f_unaccent', ['admin1Name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||||
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
|
eb(eb.fn('f_unaccent', ['alternateNames']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||||
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
|
]),
|
||||||
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
|
)
|
||||||
`,
|
.orderBy(
|
||||||
|
sql`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)
|
.limit(20)
|
||||||
.getMany();
|
.execute() as Promise<GeodataPlacesEntity[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||||
async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
|
async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
|
||||||
const parameters = [userIds.join(', '), true, false, AssetType.IMAGE];
|
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
|
||||||
const rawRes = await this.repository.query(this.assetsByCityQuery, parameters);
|
return this.prismaRepository.$queryRaw`WITH RECURSIVE cte AS (
|
||||||
|
(
|
||||||
|
SELECT city, "assetId"
|
||||||
|
FROM exif
|
||||||
|
INNER JOIN assets ON exif."assetId" = assets.id
|
||||||
|
WHERE "ownerId" = ANY(ARRAY[${userIds}]::uuid[]) AND "isVisible" = true AND "isArchived" = false AND type = 'IMAGE'
|
||||||
|
ORDER BY city
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
|
||||||
const items: AssetEntity[] = [];
|
UNION ALL
|
||||||
for (const res of rawRes) {
|
|
||||||
const item = { exifInfo: {} as Record<string, any> } as Record<string, any>;
|
|
||||||
for (const [key, value] of Object.entries(res)) {
|
|
||||||
if (key.startsWith('exif_')) {
|
|
||||||
item.exifInfo[key.replace('exif_', '')] = value;
|
|
||||||
} else {
|
|
||||||
item[key.replace('asset_', '')] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items.push(item as AssetEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
SELECT l.city, l."assetId"
|
||||||
|
FROM cte c
|
||||||
|
, LATERAL (
|
||||||
|
SELECT city, "assetId"
|
||||||
|
FROM exif
|
||||||
|
INNER JOIN assets ON exif."assetId" = assets.id
|
||||||
|
WHERE city > c.city AND "ownerId" = ANY(ARRAY[${userIds}]::uuid[]) AND "isVisible" = true AND "isArchived" = false AND type = 'IMAGE'
|
||||||
|
ORDER BY city
|
||||||
|
LIMIT 1
|
||||||
|
) l
|
||||||
|
)
|
||||||
|
select "assets".*, json_strip_nulls(to_json(exif.*)) as "exifInfo"
|
||||||
|
from "assets"
|
||||||
|
inner join "exif" on "assets"."id" = "exif"."assetId"
|
||||||
|
inner join "cte" on "assets"."id" = "cte"."assetId"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
|
async upsert(assetId: string, embedding: number[]): Promise<void> {
|
||||||
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
|
await this.prismaRepository.$kysely
|
||||||
if (!smartInfo.assetId || !embedding) {
|
.insertInto('smart_search')
|
||||||
return;
|
.values({ assetId, embedding: asVector(embedding, true) } as any)
|
||||||
}
|
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: asVector(embedding, true) } as any))
|
||||||
|
.execute();
|
||||||
await this.upsertEmbedding(smartInfo.assetId, embedding);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertEmbedding(assetId: string, embedding: number[]): Promise<void> {
|
|
||||||
await this.smartSearchRepository.upsert(
|
|
||||||
{ assetId, embedding: () => asVector(embedding, true) },
|
|
||||||
{ conflictPaths: ['assetId'] },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateDimSize(dimSize: number): Promise<void> {
|
private async updateDimSize(dimSize: number): Promise<void> {
|
||||||
@ -275,27 +228,27 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
|
|
||||||
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
|
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
|
||||||
|
|
||||||
await this.smartSearchRepository.manager.transaction(async (manager) => {
|
this.prismaRepository.$transaction(async (tx) => {
|
||||||
await manager.clear(SmartSearchEntity);
|
await tx.$queryRawUnsafe(`TRUNCATE smart_search`);
|
||||||
await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
|
await tx.$queryRawUnsafe(`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}.`);
|
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAllSearchEmbeddings(): Promise<void> {
|
deleteAllSearchEmbeddings(): Promise<void> {
|
||||||
return this.smartSearchRepository.clear();
|
return this.prismaRepository.$queryRawUnsafe(`TRUNCATE smart_search`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDimSize(): Promise<number> {
|
private async getDimSize(): Promise<number> {
|
||||||
const res = await this.smartSearchRepository.manager.query(`
|
const res = await this.prismaRepository.$queryRaw<[{ dimsize: number }]>`
|
||||||
SELECT atttypmod as dimsize
|
SELECT atttypmod as dimsize
|
||||||
FROM pg_attribute f
|
FROM pg_attribute f
|
||||||
JOIN pg_class c ON c.oid = f.attrelid
|
JOIN pg_class c ON c.oid = f.attrelid
|
||||||
WHERE c.relkind = 'r'::char
|
WHERE c.relkind = 'r'::char
|
||||||
AND f.attnum > 0
|
AND f.attnum > 0
|
||||||
AND c.relname = 'smart_search'
|
AND c.relname = 'smart_search'
|
||||||
AND f.attname = 'embedding'`);
|
AND f.attname = 'embedding'`;
|
||||||
|
|
||||||
const dimSize = res[0]['dimsize'];
|
const dimSize = res[0]['dimsize'];
|
||||||
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
||||||
@ -304,43 +257,110 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
return dimSize;
|
return dimSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRuntimeConfig(numResults?: number): string {
|
private searchAssetBuilder(options: AssetSearchBuilderOptions, kysely: Kysely<DB> = this.prismaRepository.$kysely) {
|
||||||
if (vectorExt === DatabaseExtension.VECTOR) {
|
const withExif = (eb: ExpressionBuilder<DB, 'assets'>) =>
|
||||||
return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall
|
jsonObjectFrom(eb.selectFrom('exif').selectAll().whereRef('exif.assetId', '=', 'assets.id')).as('exifInfo');
|
||||||
}
|
|
||||||
|
|
||||||
let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;';
|
const withSmartInfo = (eb: ExpressionBuilder<DB, 'assets'>) =>
|
||||||
if (numResults) {
|
jsonObjectFrom(eb.selectFrom('smart_info').selectAll().whereRef('smart_info.assetId', '=', 'assets.id')).as(
|
||||||
runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`;
|
'smartInfo',
|
||||||
}
|
);
|
||||||
|
|
||||||
return runtimeConfig;
|
const withFaces = (eb: ExpressionBuilder<DB, 'assets'>) =>
|
||||||
|
jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as(
|
||||||
|
'faces',
|
||||||
|
);
|
||||||
|
|
||||||
|
const withPeople = (eb: ExpressionBuilder<DB, 'assets' | 'asset_faces'>) =>
|
||||||
|
jsonObjectFrom(eb.selectFrom('person').selectAll().whereRef('asset_faces.personId', '=', 'person.id')).as(
|
||||||
|
'people',
|
||||||
|
);
|
||||||
|
|
||||||
|
options.isArchived ??= options.withArchived ? undefined : false;
|
||||||
|
options.withDeleted ??= !!(options.trashedAfter || options.trashedBefore);
|
||||||
|
const query = kysely
|
||||||
|
.selectFrom('assets')
|
||||||
|
.selectAll('assets')
|
||||||
|
.$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore as Date))
|
||||||
|
.$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter as Date))
|
||||||
|
.$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore as Date))
|
||||||
|
.$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter as Date))
|
||||||
|
.$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore as Date))
|
||||||
|
.$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter as Date))
|
||||||
|
.$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore as Date))
|
||||||
|
.$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter as Date))
|
||||||
|
.$if(!!options.city, (qb) =>
|
||||||
|
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.city', '=', options.city as string),
|
||||||
|
)
|
||||||
|
.$if(!!options.country, (qb) =>
|
||||||
|
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.country', '=', options.country as string),
|
||||||
|
)
|
||||||
|
.$if(!!options.lensModel, (qb) =>
|
||||||
|
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.lensModel', '=', options.lensModel as string),
|
||||||
|
)
|
||||||
|
.$if(!!options.make, (qb) =>
|
||||||
|
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.make', '=', options.make as string),
|
||||||
|
)
|
||||||
|
.$if(!!options.model, (qb) =>
|
||||||
|
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.model', '=', options.model as string),
|
||||||
|
)
|
||||||
|
.$if(!!options.state, (qb) =>
|
||||||
|
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.state', '=', options.state as string),
|
||||||
|
)
|
||||||
|
.$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum as Buffer))
|
||||||
|
.$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId as string))
|
||||||
|
.$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId as string))
|
||||||
|
.$if(!!options.id, (qb) => qb.where('assets.id', '=', options.id as string))
|
||||||
|
.$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', options.libraryId as string))
|
||||||
|
.$if(!!options.userIds, (qb) =>
|
||||||
|
qb.where('assets.ownerId', '=', sql<string>`ANY(ARRAY[${options.userIds}]::uuid[])`),
|
||||||
|
)
|
||||||
|
.$if(!!options.encodedVideoPath, (qb) =>
|
||||||
|
qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath as string),
|
||||||
|
)
|
||||||
|
.$if(!!options.originalPath, (qb) => qb.where('assets.originalPath', '=', options.originalPath as string))
|
||||||
|
.$if(!!options.resizePath, (qb) => qb.where('assets.resizePath', '=', options.resizePath as string))
|
||||||
|
.$if(!!options.webpPath, (qb) => qb.where('assets.webpPath', '=', options.webpPath as string))
|
||||||
|
.$if(!!options.originalFileName, (qb) =>
|
||||||
|
qb.where(sql`f_unaccent(assets.originalFileName)`, 'ilike', sql`f_unaccent(${options.originalFileName})`),
|
||||||
|
)
|
||||||
|
.$if(!!options.isExternal, (qb) => qb.where('assets.isExternal', '=', options.isExternal as boolean))
|
||||||
|
.$if(!!options.isFavorite, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite as boolean))
|
||||||
|
.$if(!!options.isOffline, (qb) => qb.where('assets.isOffline', '=', options.isOffline as boolean))
|
||||||
|
.$if(!!options.isReadOnly, (qb) => qb.where('assets.isReadOnly', '=', options.isReadOnly as boolean))
|
||||||
|
.$if(!!options.isVisible, (qb) => qb.where('assets.isVisible', '=', options.isVisible as boolean))
|
||||||
|
.$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type as AssetType))
|
||||||
|
.$if(!!options.isArchived, (qb) => qb.where('assets.isArchived', '=', options.isArchived as boolean))
|
||||||
|
.$if(!!options.isEncoded, (qb) => qb.where('assets.encodedVideoPath', 'is not', null))
|
||||||
|
.$if(!!options.isMotion, (qb) => qb.where('assets.livePhotoVideoId', 'is not', null))
|
||||||
|
.$if(!!options.isNotInAlbum, (qb) =>
|
||||||
|
qb
|
||||||
|
.leftJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
||||||
|
.where('albums_assets_assets.assetsId', 'is', null),
|
||||||
|
)
|
||||||
|
.$if(!!options.withExif, (qb) => qb.select((eb) => withExif(eb)))
|
||||||
|
.$if(!!options.withSmartInfo, (qb) => qb.select((eb) => withSmartInfo(eb)))
|
||||||
|
.$if(!(!options.withFaces || options.withPeople), (qb) =>
|
||||||
|
qb.select((eb) => withFaces(eb)).$if(!!options.withPeople, (qb) => qb.select((eb) => withPeople(eb) as any)),
|
||||||
|
)
|
||||||
|
.$if(!!options.personIds && options.personIds.length > 0, (qb) =>
|
||||||
|
qb.innerJoin(
|
||||||
|
(eb: any) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('asset_faces')
|
||||||
|
.select('asset_faces.assetId')
|
||||||
|
.where('asset_faces.personId', '=', sql`ANY(ARRAY[${options.personIds}]::uuid[])`)
|
||||||
|
.groupBy('asset_faces.assetId')
|
||||||
|
.having(
|
||||||
|
(eb: any) => eb.fn.count('asset_faces.personId').distinct(),
|
||||||
|
'=',
|
||||||
|
(options.personIds as string[]).length,
|
||||||
|
)
|
||||||
|
.as('personAssetIds'),
|
||||||
|
(join) => join.onRef('personAssetIds.assetId' as any, '=', 'assets.id' as any),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
|
|
||||||
const assetsByCityCte = `
|
|
||||||
WITH RECURSIVE cte AS (
|
|
||||||
(
|
|
||||||
SELECT city, "assetId"
|
|
||||||
FROM exif
|
|
||||||
INNER JOIN assets ON exif."assetId" = assets.id
|
|
||||||
WHERE "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
|
|
||||||
ORDER BY city
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT l.city, l."assetId"
|
|
||||||
FROM cte c
|
|
||||||
, LATERAL (
|
|
||||||
SELECT city, "assetId"
|
|
||||||
FROM exif
|
|
||||||
INNER JOIN assets ON exif."assetId" = assets.id
|
|
||||||
WHERE city > c.city AND "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
|
|
||||||
ORDER BY city
|
|
||||||
LIMIT 1
|
|
||||||
) l
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
@ -6,19 +6,11 @@ import sanitize from 'sanitize-filename';
|
|||||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
|
||||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
||||||
import {
|
import {
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
MemoryLaneResponseDto,
|
MemoryLaneResponseDto,
|
||||||
SanitizedAssetResponseDto,
|
SanitizedAssetResponseDto,
|
||||||
mapAsset,
|
mapAsset,
|
||||||
} from 'src/dtos/asset-response.dto';
|
|
||||||
AssetResponseDto,
|
|
||||||
MemoryLaneResponseDto,
|
|
||||||
SanitizedAssetResponseDto,
|
|
||||||
mapAsset,
|
|
||||||
} from 'src/dtos/asset-response.dto';
|
} from 'src/dtos/asset-response.dto';
|
||||||
import {
|
import {
|
||||||
AssetBulkDeleteDto,
|
AssetBulkDeleteDto,
|
||||||
@ -28,7 +20,6 @@ import {
|
|||||||
AssetStatsDto,
|
AssetStatsDto,
|
||||||
UpdateAssetDto,
|
UpdateAssetDto,
|
||||||
UploadFieldName,
|
UploadFieldName,
|
||||||
UploadFieldName,
|
|
||||||
mapStats,
|
mapStats,
|
||||||
} from 'src/dtos/asset.dto';
|
} from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
@ -98,7 +98,7 @@ export class SmartInfoService {
|
|||||||
await this.databaseRepository.wait(DatabaseLock.CLIPDimSize);
|
await this.databaseRepository.wait(DatabaseLock.CLIPDimSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.repository.upsert({ assetId: asset.id }, clipEmbedding);
|
await this.repository.upsert(asset.id, clipEmbedding);
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"preserveWatchOutput": true,
|
"preserveWatchOutput": true,
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
"noErrorTruncation": true,
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist",
|
"dist",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user