2023-05-17 19:07:17 +02:00
|
|
|
import { AlbumEntity, AssetEntity, AssetFaceEntity } from '@app/infra/entities';
|
2023-03-03 03:47:08 +01:00
|
|
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
|
|
|
import { ConfigService } from '@nestjs/config';
|
2023-03-18 14:44:42 +01:00
|
|
|
import { mapAlbum } from '../album';
|
2023-03-03 03:47:08 +01:00
|
|
|
import { IAlbumRepository } from '../album/album.repository';
|
2023-03-18 14:44:42 +01:00
|
|
|
import { mapAsset } from '../asset';
|
2023-03-03 03:47:08 +01:00
|
|
|
import { IAssetRepository } from '../asset/asset.repository';
|
|
|
|
import { AuthUserDto } from '../auth';
|
2023-03-24 05:55:15 +01:00
|
|
|
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
2023-05-17 19:07:17 +02:00
|
|
|
import { AssetFaceId, IFaceRepository } from '../facial-recognition';
|
|
|
|
import { IAssetFaceJob, IBulkEntityJob, IJobRepository, JobName } from '../job';
|
2023-03-18 14:44:42 +01:00
|
|
|
import { IMachineLearningRepository } from '../smart-info';
|
2023-03-03 03:47:08 +01:00
|
|
|
import { SearchDto } from './dto';
|
|
|
|
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
|
2023-03-18 14:44:42 +01:00
|
|
|
import {
|
|
|
|
ISearchRepository,
|
2023-05-17 19:07:17 +02:00
|
|
|
OwnedFaceEntity,
|
2023-03-18 14:44:42 +01:00
|
|
|
SearchCollection,
|
|
|
|
SearchExploreItem,
|
|
|
|
SearchResult,
|
|
|
|
SearchStrategy,
|
|
|
|
} from './search.repository';
|
|
|
|
|
|
|
|
interface SyncQueue {
|
|
|
|
upsert: Set<string>;
|
|
|
|
delete: Set<string>;
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class SearchService {
|
|
|
|
private logger = new Logger(SearchService.name);
|
|
|
|
private enabled: boolean;
|
2023-03-18 14:44:42 +01:00
|
|
|
private timer: NodeJS.Timer | null = null;
|
|
|
|
|
|
|
|
private albumQueue: SyncQueue = {
|
|
|
|
upsert: new Set(),
|
|
|
|
delete: new Set(),
|
|
|
|
};
|
|
|
|
|
|
|
|
private assetQueue: SyncQueue = {
|
|
|
|
upsert: new Set(),
|
|
|
|
delete: new Set(),
|
|
|
|
};
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-05-17 19:07:17 +02:00
|
|
|
private faceQueue: SyncQueue = {
|
|
|
|
upsert: new Set(),
|
|
|
|
delete: new Set(),
|
|
|
|
};
|
|
|
|
|
2023-03-03 03:47:08 +01:00
|
|
|
constructor(
|
|
|
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
|
|
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
2023-05-17 19:07:17 +02:00
|
|
|
@Inject(IFaceRepository) private faceRepository: IFaceRepository,
|
2023-03-03 03:47:08 +01:00
|
|
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
2023-03-18 14:44:42 +01:00
|
|
|
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
2023-03-03 03:47:08 +01:00
|
|
|
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
|
|
|
configService: ConfigService,
|
|
|
|
) {
|
|
|
|
this.enabled = configService.get('TYPESENSE_ENABLED') !== 'false';
|
2023-03-18 14:44:42 +01:00
|
|
|
if (this.enabled) {
|
|
|
|
this.timer = setInterval(() => this.flush(), 5_000);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
teardown() {
|
|
|
|
if (this.timer) {
|
|
|
|
clearInterval(this.timer);
|
|
|
|
this.timer = null;
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
isEnabled() {
|
|
|
|
return this.enabled;
|
|
|
|
}
|
|
|
|
|
|
|
|
getConfig(): SearchConfigResponseDto {
|
|
|
|
return {
|
|
|
|
enabled: this.enabled,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
async bootstrap() {
|
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.logger.log('Running bootstrap');
|
|
|
|
await this.searchRepository.setup();
|
|
|
|
|
|
|
|
const migrationStatus = await this.searchRepository.checkMigrationStatus();
|
|
|
|
if (migrationStatus[SearchCollection.ASSETS]) {
|
|
|
|
this.logger.debug('Queueing job to re-index all assets');
|
|
|
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSETS });
|
|
|
|
}
|
|
|
|
if (migrationStatus[SearchCollection.ALBUMS]) {
|
|
|
|
this.logger.debug('Queueing job to re-index all albums');
|
|
|
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS });
|
|
|
|
}
|
2023-05-17 19:07:17 +02:00
|
|
|
if (migrationStatus[SearchCollection.FACES]) {
|
|
|
|
this.logger.debug('Queueing job to re-index all faces');
|
|
|
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES });
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
|
|
|
|
2023-03-05 21:44:31 +01:00
|
|
|
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
|
|
|
|
this.assertEnabled();
|
|
|
|
return this.searchRepository.explore(authUser.id);
|
|
|
|
}
|
|
|
|
|
2023-03-03 03:47:08 +01:00
|
|
|
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
|
2023-03-05 21:44:31 +01:00
|
|
|
this.assertEnabled();
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
const query = dto.q || dto.query || '*';
|
2023-03-18 22:30:48 +01:00
|
|
|
const strategy = dto.clip && MACHINE_LEARNING_ENABLED ? SearchStrategy.CLIP : SearchStrategy.TEXT;
|
2023-03-18 14:44:42 +01:00
|
|
|
const filters = { userId: authUser.id, ...dto };
|
|
|
|
|
|
|
|
let assets: SearchResult<AssetEntity>;
|
|
|
|
switch (strategy) {
|
|
|
|
case SearchStrategy.CLIP:
|
|
|
|
const clip = await this.machineLearning.encodeText(query);
|
|
|
|
assets = await this.searchRepository.vectorSearch(clip, filters);
|
2023-03-19 14:20:23 +01:00
|
|
|
break;
|
2023-03-18 22:30:48 +01:00
|
|
|
case SearchStrategy.TEXT:
|
|
|
|
default:
|
|
|
|
assets = await this.searchRepository.searchAssets(query, filters);
|
|
|
|
break;
|
2023-03-18 14:44:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const albums = await this.searchRepository.searchAlbums(query, filters);
|
2023-03-03 03:47:08 +01:00
|
|
|
|
|
|
|
return {
|
2023-03-18 14:44:42 +01:00
|
|
|
albums: { ...albums, items: albums.items.map(mapAlbum) },
|
|
|
|
assets: { ...assets, items: assets.items.map(mapAsset) },
|
2023-03-03 03:47:08 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
async handleIndexAlbums() {
|
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const albums = this.patchAlbums(await this.albumRepository.getAll());
|
|
|
|
this.logger.log(`Indexing ${albums.length} albums`);
|
|
|
|
await this.searchRepository.importAlbums(albums, true);
|
|
|
|
} catch (error: any) {
|
|
|
|
this.logger.error(`Unable to index all albums`, error?.stack);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-03 03:47:08 +01:00
|
|
|
async handleIndexAssets() {
|
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
// TODO: do this in batches based on searchIndexVersion
|
2023-03-18 14:44:42 +01:00
|
|
|
const assets = this.patchAssets(await this.assetRepository.getAll({ isVisible: true }));
|
2023-03-03 03:47:08 +01:00
|
|
|
this.logger.log(`Indexing ${assets.length} assets`);
|
2023-03-20 21:16:32 +01:00
|
|
|
|
|
|
|
const chunkSize = 1000;
|
|
|
|
for (let i = 0; i < assets.length; i += chunkSize) {
|
2023-03-22 06:36:32 +01:00
|
|
|
await this.searchRepository.importAssets(assets.slice(i, i + chunkSize), false);
|
2023-03-20 21:16:32 +01:00
|
|
|
}
|
|
|
|
|
2023-03-22 06:36:32 +01:00
|
|
|
await this.searchRepository.importAssets([], true);
|
|
|
|
|
2023-03-05 21:44:31 +01:00
|
|
|
this.logger.debug('Finished re-indexing all assets');
|
2023-03-03 03:47:08 +01:00
|
|
|
} catch (error: any) {
|
|
|
|
this.logger.error(`Unable to index all assets`, error?.stack);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-17 19:07:17 +02:00
|
|
|
async handleIndexFaces() {
|
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
// TODO: do this in batches based on searchIndexVersion
|
|
|
|
const faces = this.patchFaces(await this.faceRepository.getAll());
|
|
|
|
this.logger.log(`Indexing ${faces.length} faces`);
|
|
|
|
|
|
|
|
const chunkSize = 1000;
|
|
|
|
for (let i = 0; i < faces.length; i += chunkSize) {
|
|
|
|
await this.searchRepository.importFaces(faces.slice(i, i + chunkSize), false);
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.searchRepository.importFaces([], true);
|
|
|
|
|
|
|
|
this.logger.debug('Finished re-indexing all faces');
|
|
|
|
} catch (error: any) {
|
|
|
|
this.logger.error(`Unable to index all faces`, error?.stack);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
handleIndexAlbum({ ids }: IBulkEntityJob) {
|
2023-03-03 03:47:08 +01:00
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
for (const id of ids) {
|
|
|
|
this.albumQueue.upsert.add(id);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
handleIndexAsset({ ids }: IBulkEntityJob) {
|
|
|
|
if (!this.enabled) {
|
2023-03-05 21:44:31 +01:00
|
|
|
return;
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
for (const id of ids) {
|
|
|
|
this.assetQueue.upsert.add(id);
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-17 19:07:17 +02:00
|
|
|
async handleIndexFace({ assetId, personId }: IAssetFaceJob) {
|
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// immediately push to typesense
|
|
|
|
await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false);
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
handleRemoveAlbum({ ids }: IBulkEntityJob) {
|
2023-03-03 03:47:08 +01:00
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
for (const id of ids) {
|
|
|
|
this.albumQueue.delete.add(id);
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
handleRemoveAsset({ ids }: IBulkEntityJob) {
|
2023-03-03 03:47:08 +01:00
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
for (const id of ids) {
|
|
|
|
this.assetQueue.delete.add(id);
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-17 19:07:17 +02:00
|
|
|
handleRemoveFace({ assetId, personId }: IAssetFaceJob) {
|
|
|
|
if (!this.enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.faceQueue.delete.add(this.asKey({ assetId, personId }));
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
private async flush() {
|
|
|
|
if (this.albumQueue.upsert.size > 0) {
|
|
|
|
const ids = [...this.albumQueue.upsert.keys()];
|
|
|
|
const items = await this.idsToAlbums(ids);
|
|
|
|
this.logger.debug(`Flushing ${items.length} album upserts`);
|
|
|
|
await this.searchRepository.importAlbums(items, false);
|
|
|
|
this.albumQueue.upsert.clear();
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
if (this.albumQueue.delete.size > 0) {
|
|
|
|
const ids = [...this.albumQueue.delete.keys()];
|
|
|
|
this.logger.debug(`Flushing ${ids.length} album deletes`);
|
|
|
|
await this.searchRepository.deleteAlbums(ids);
|
|
|
|
this.albumQueue.delete.clear();
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
if (this.assetQueue.upsert.size > 0) {
|
|
|
|
const ids = [...this.assetQueue.upsert.keys()];
|
|
|
|
const items = await this.idsToAssets(ids);
|
|
|
|
this.logger.debug(`Flushing ${items.length} asset upserts`);
|
|
|
|
await this.searchRepository.importAssets(items, false);
|
|
|
|
this.assetQueue.upsert.clear();
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
if (this.assetQueue.delete.size > 0) {
|
|
|
|
const ids = [...this.assetQueue.delete.keys()];
|
|
|
|
this.logger.debug(`Flushing ${ids.length} asset deletes`);
|
|
|
|
await this.searchRepository.deleteAssets(ids);
|
|
|
|
this.assetQueue.delete.clear();
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
2023-05-17 19:07:17 +02:00
|
|
|
|
|
|
|
if (this.faceQueue.upsert.size > 0) {
|
|
|
|
const ids = [...this.faceQueue.upsert.keys()].map((key) => this.asParts(key));
|
|
|
|
const items = await this.idsToFaces(ids);
|
|
|
|
this.logger.debug(`Flushing ${items.length} face upserts`);
|
|
|
|
await this.searchRepository.importFaces(items, false);
|
|
|
|
this.faceQueue.upsert.clear();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.faceQueue.delete.size > 0) {
|
|
|
|
const ids = [...this.faceQueue.delete.keys()];
|
|
|
|
this.logger.debug(`Flushing ${ids.length} face deletes`);
|
|
|
|
await this.searchRepository.deleteFaces(ids);
|
|
|
|
this.faceQueue.delete.clear();
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|
2023-03-05 21:44:31 +01:00
|
|
|
|
|
|
|
private assertEnabled() {
|
|
|
|
if (!this.enabled) {
|
|
|
|
throw new BadRequestException('Search is disabled');
|
|
|
|
}
|
|
|
|
}
|
2023-03-18 14:44:42 +01:00
|
|
|
|
|
|
|
private async idsToAlbums(ids: string[]): Promise<AlbumEntity[]> {
|
|
|
|
const entities = await this.albumRepository.getByIds(ids);
|
|
|
|
return this.patchAlbums(entities);
|
|
|
|
}
|
|
|
|
|
|
|
|
private async idsToAssets(ids: string[]): Promise<AssetEntity[]> {
|
|
|
|
const entities = await this.assetRepository.getByIds(ids);
|
|
|
|
return this.patchAssets(entities.filter((entity) => entity.isVisible));
|
|
|
|
}
|
|
|
|
|
2023-05-17 19:07:17 +02:00
|
|
|
private async idsToFaces(ids: AssetFaceId[]): Promise<OwnedFaceEntity[]> {
|
|
|
|
return this.patchFaces(await this.faceRepository.getByIds(ids));
|
|
|
|
}
|
|
|
|
|
2023-03-18 14:44:42 +01:00
|
|
|
private patchAssets(assets: AssetEntity[]): AssetEntity[] {
|
|
|
|
return assets;
|
|
|
|
}
|
|
|
|
|
|
|
|
private patchAlbums(albums: AlbumEntity[]): AlbumEntity[] {
|
|
|
|
return albums.map((entity) => ({ ...entity, assets: [] }));
|
|
|
|
}
|
2023-05-17 19:07:17 +02:00
|
|
|
|
|
|
|
private patchFaces(faces: AssetFaceEntity[]): OwnedFaceEntity[] {
|
|
|
|
return faces.map((face) => ({
|
|
|
|
id: this.asKey(face),
|
|
|
|
ownerId: face.asset.ownerId,
|
|
|
|
assetId: face.assetId,
|
|
|
|
personId: face.personId,
|
|
|
|
embedding: face.embedding,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
private asKey(face: AssetFaceId): string {
|
|
|
|
return `${face.assetId}|${face.personId}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
private asParts(key: string): AssetFaceId {
|
|
|
|
const [assetId, personId] = key.split('|');
|
|
|
|
return { assetId, personId };
|
|
|
|
}
|
2023-03-03 03:47:08 +01:00
|
|
|
}
|