feat(server)!: search via typesense (#1778)

* build: add typesense to docker

* feat(server): typesense search

* feat(web): search

* fix(web): show api error response message

* chore: search tests

* chore: regenerate open api

* fix: disable typesense on e2e

* fix: number properties for open api (dart)

* fix: e2e test

* fix: change lat/lng from floats to typesense geopoint

* dev: Add smartInfo relation to findAssetById to be able to query against it

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2023-03-02 21:47:08 -05:00
committed by GitHub
parent 1cc184ed10
commit 0aaeab124d
87 changed files with 3638 additions and 77 deletions

View File

@@ -1,5 +1,9 @@
import { AlbumEntity } from '@app/infra/db/entities';
export const IAlbumRepository = 'IAlbumRepository';
export interface IAlbumRepository {
deleteAll(userId: string): Promise<void>;
getAll(): Promise<AlbumEntity[]>;
save(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
}

View File

@@ -0,0 +1,21 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { ISearchRepository, SearchCollection } from '../search/search.repository';
import { AssetSearchOptions, IAssetRepository } from './asset.repository';
export class AssetCore {
constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {}
getAll(options: AssetSearchOptions) {
return this.repository.getAll(options);
}
async save(asset: Partial<AssetEntity>) {
const _asset = await this.repository.save(asset);
await this.searchRepository.index(SearchCollection.ASSETS, _asset);
return _asset;
}
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
}
}

View File

@@ -1,10 +1,14 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
export interface AssetSearchOptions {
isVisible?: boolean;
}
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
deleteAll(ownerId: string): Promise<void>;
getAll(): Promise<AssetEntity[]>;
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null>;
}

View File

@@ -1,19 +1,25 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { newJobRepositoryMock } from '../../test';
import { AssetService } from '../asset';
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { newSearchRepositoryMock } from '../../test/search.repository.mock';
import { AssetService, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
import { ISearchRepository } from '../search';
describe(AssetService.name, () => {
let sut: AssetService;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let searchMock: jest.Mocked<ISearchRepository>;
it('should work', () => {
expect(sut).toBeDefined();
});
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new AssetService(jobMock);
searchMock = newSearchRepositoryMock();
sut = new AssetService(assetMock, jobMock, searchMock);
});
describe(`handle asset upload`, () => {
@@ -42,4 +48,15 @@ describe(AssetService.name, () => {
]);
});
});
describe('save', () => {
it('should save an asset', async () => {
assetMock.save.mockResolvedValue(assetEntityStub.image);
await sut.save(assetEntityStub.image);
expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
});
});
});

View File

@@ -1,9 +1,20 @@
import { AssetType } from '@app/infra/db/entities';
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { Inject } from '@nestjs/common';
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
import { ISearchRepository } from '../search';
import { AssetCore } from './asset.core';
import { IAssetRepository } from './asset.repository';
export class AssetService {
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) searchRepository: ISearchRepository,
) {
this.assetCore = new AssetCore(assetRepository, searchRepository);
}
async handleAssetUpload(data: IAssetUploadedJob) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data });
@@ -15,4 +26,8 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data });
}
}
save(asset: Partial<AssetEntity>) {
return this.assetCore.save(asset);
}
}

View File

@@ -1,3 +1,4 @@
export * from './asset.core';
export * from './asset.repository';
export * from './asset.service';
export * from './response-dto';

View File

@@ -5,6 +5,7 @@ import { AuthService } from './auth';
import { DeviceInfoService } from './device-info';
import { MediaService } from './media';
import { OAuthService } from './oauth';
import { SearchService } from './search';
import { ShareService } from './share';
import { SmartInfoService } from './smart-info';
import { StorageService } from './storage';
@@ -25,6 +26,7 @@ const providers: Provider[] = [
SystemConfigService,
UserService,
ShareService,
SearchService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [SystemConfigService],

View File

@@ -9,6 +9,7 @@ export * from './domain.module';
export * from './job';
export * from './media';
export * from './oauth';
export * from './search';
export * from './share';
export * from './smart-info';
export * from './storage';

View File

@@ -5,6 +5,7 @@ export enum QueueName {
MACHINE_LEARNING = 'machine-learning-queue',
BACKGROUND_TASK = 'background-task',
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
SEARCH = 'search-queue',
}
export enum JobName {
@@ -22,4 +23,10 @@ export enum JobName {
OBJECT_DETECTION = 'detect-object',
IMAGE_TAGGING = 'tag-image',
DELETE_FILES = 'delete-files',
SEARCH_INDEX_ASSETS = 'search-index-assets',
SEARCH_INDEX_ASSET = 'search-index-asset',
SEARCH_INDEX_ALBUMS = 'search-index-albums',
SEARCH_INDEX_ALBUM = 'search-index-album',
SEARCH_REMOVE_ALBUM = 'search-remove-album',
SEARCH_REMOVE_ASSET = 'search-remove-asset',
}

View File

@@ -1,4 +1,8 @@
import { AssetEntity, UserEntity } from '@app/infra/db/entities';
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
export interface IAlbumJob {
album: AlbumEntity;
}
export interface IAssetJob {
asset: AssetEntity;
@@ -9,6 +13,10 @@ export interface IAssetUploadedJob {
fileName: string;
}
export interface IDeleteJob {
id: string;
}
export interface IDeleteFilesJob {
files: Array<string | null | undefined>;
}

View File

@@ -1,5 +1,13 @@
import { JobName, QueueName } from './job.constants';
import { IAssetJob, IAssetUploadedJob, IDeleteFilesJob, IReverseGeocodingJob, IUserDeletionJob } from './job.interface';
import {
IAlbumJob,
IAssetJob,
IAssetUploadedJob,
IDeleteFilesJob,
IDeleteJob,
IReverseGeocodingJob,
IUserDeletionJob,
} from './job.interface';
export interface JobCounts {
active: number;
@@ -23,7 +31,13 @@ export type JobItem =
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
| { name: JobName.OBJECT_DETECTION; data: IAssetJob }
| { name: JobName.IMAGE_TAGGING; data: IAssetJob }
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob };
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
| { name: JobName.SEARCH_INDEX_ASSETS }
| { name: JobName.SEARCH_INDEX_ASSET; data: IAssetJob }
| { name: JobName.SEARCH_INDEX_ALBUMS }
| { name: JobName.SEARCH_INDEX_ALBUM; data: IAlbumJob }
| { name: JobName.SEARCH_REMOVE_ASSET; data: IDeleteJob }
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IDeleteJob };
export const IJobRepository = 'IJobRepository';

View File

@@ -0,0 +1 @@
export * from './search.dto';

View File

@@ -0,0 +1,57 @@
import { AssetType } from '@app/infra/db/entities';
import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toBoolean } from '../../../../../apps/immich/src/utils/transform.util';
export class SearchDto {
@IsString()
@IsNotEmpty()
@IsOptional()
query?: string;
@IsEnum(AssetType)
@IsOptional()
type?: AssetType;
@IsBoolean()
@IsOptional()
@Transform(toBoolean)
isFavorite?: boolean;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.city'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.state'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.country'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.make'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.model'?: string;
@IsString({ each: true })
@IsArray()
@IsOptional()
@Transform(({ value }) => value.split(','))
'smartInfo.objects'?: string[];
@IsString({ each: true })
@IsArray()
@IsOptional()
@Transform(({ value }) => value.split(','))
'smartInfo.tags'?: string[];
}

View File

@@ -0,0 +1,4 @@
export * from './dto';
export * from './response-dto';
export * from './search.repository';
export * from './search.service';

View File

@@ -0,0 +1,2 @@
export * from './search-config-response.dto';
export * from './search-response.dto';

View File

@@ -0,0 +1,3 @@
export class SearchConfigResponseDto {
enabled!: boolean;
}

View File

@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { AlbumResponseDto } from '../../album';
import { AssetResponseDto } from '../../asset';
class SearchFacetCountResponseDto {
@ApiProperty({ type: 'integer' })
count!: number;
value!: string;
}
class SearchFacetResponseDto {
fieldName!: string;
counts!: SearchFacetCountResponseDto[];
}
class SearchAlbumResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
@ApiProperty({ type: 'integer' })
count!: number;
items!: AlbumResponseDto[];
facets!: SearchFacetResponseDto[];
}
class SearchAssetResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
@ApiProperty({ type: 'integer' })
count!: number;
items!: AssetResponseDto[];
facets!: SearchFacetResponseDto[];
}
export class SearchResponseDto {
albums!: SearchAlbumResponseDto;
assets!: SearchAssetResponseDto;
}

View File

@@ -0,0 +1,60 @@
import { AlbumEntity, AssetEntity, AssetType } from '@app/infra/db/entities';
export enum SearchCollection {
ASSETS = 'assets',
ALBUMS = 'albums',
}
export interface SearchFilter {
id?: string;
userId: string;
type?: AssetType;
isFavorite?: boolean;
city?: string;
state?: string;
country?: string;
make?: string;
model?: string;
objects?: string[];
tags?: string[];
}
export interface SearchResult<T> {
/** total matches */
total: number;
/** collection size */
count: number;
/** current page */
page: number;
/** items for page */
items: T[];
facets: SearchFacet[];
}
export interface SearchFacet {
fieldName: string;
counts: Array<{
count: number;
value: string;
}>;
}
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
export const ISearchRepository = 'ISearchRepository';
export interface ISearchRepository {
setup(): Promise<void>;
checkMigrationStatus(): Promise<SearchCollectionIndexStatus>;
index(collection: SearchCollection.ASSETS, item: AssetEntity): Promise<void>;
index(collection: SearchCollection.ALBUMS, item: AlbumEntity): Promise<void>;
delete(collection: SearchCollection, id: string): Promise<void>;
import(collection: SearchCollection.ASSETS, items: AssetEntity[], done: boolean): Promise<void>;
import(collection: SearchCollection.ALBUMS, items: AlbumEntity[], done: boolean): Promise<void>;
search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
}

View File

@@ -0,0 +1,317 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { plainToInstance } from 'class-transformer';
import {
albumStub,
assetEntityStub,
authStub,
newAlbumRepositoryMock,
newAssetRepositoryMock,
newJobRepositoryMock,
newSearchRepositoryMock,
} from '../../test';
import { IAlbumRepository } from '../album/album.repository';
import { IAssetRepository } from '../asset/asset.repository';
import { JobName } from '../job';
import { IJobRepository } from '../job/job.repository';
import { SearchDto } from './dto';
import { ISearchRepository } from './search.repository';
import { SearchService } from './search.service';
describe(SearchService.name, () => {
let sut: SearchService;
let albumMock: jest.Mocked<IAlbumRepository>;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let searchMock: jest.Mocked<ISearchRepository>;
let configMock: jest.Mocked<ConfigService>;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
searchMock = newSearchRepositoryMock();
configMock = { get: jest.fn() } as unknown as jest.Mocked<ConfigService>;
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('request dto', () => {
it('should convert smartInfo.tags to a string list', () => {
const instance = plainToInstance(SearchDto, { 'smartInfo.tags': 'a,b,c' });
expect(instance['smartInfo.tags']).toEqual(['a', 'b', 'c']);
});
it('should handle empty smartInfo.tags', () => {
const instance = plainToInstance(SearchDto, {});
expect(instance['smartInfo.tags']).toBeUndefined();
});
it('should convert smartInfo.objects to a string list', () => {
const instance = plainToInstance(SearchDto, { 'smartInfo.objects': 'a,b,c' });
expect(instance['smartInfo.objects']).toEqual(['a', 'b', 'c']);
});
it('should handle empty smartInfo.objects', () => {
const instance = plainToInstance(SearchDto, {});
expect(instance['smartInfo.objects']).toBeUndefined();
});
});
describe('isEnabled', () => {
it('should be enabled by default', () => {
expect(sut.isEnabled()).toBe(true);
});
it('should be disabled via an env variable', () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
expect(sut.isEnabled()).toBe(false);
});
});
describe('getConfig', () => {
it('should return the config', () => {
expect(sut.getConfig()).toEqual({ enabled: true });
});
it('should return the config when search is disabled', () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
expect(sut.getConfig()).toEqual({ enabled: false });
});
});
describe(`bootstrap`, () => {
it('should skip when search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.bootstrap();
expect(searchMock.setup).not.toHaveBeenCalled();
expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should skip schema migration if not needed', async () => {
searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false });
await sut.bootstrap();
expect(searchMock.setup).toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should do schema migration if needed', async () => {
searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true });
await sut.bootstrap();
expect(searchMock.setup).toHaveBeenCalled();
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_INDEX_ASSETS }],
[{ name: JobName.SEARCH_INDEX_ALBUMS }],
]);
});
});
describe('search', () => {
it('should throw an error is search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException);
expect(searchMock.search).not.toHaveBeenCalled();
});
it('should search assets and albums', async () => {
searchMock.search.mockResolvedValue({
total: 0,
count: 0,
page: 1,
items: [],
facets: [],
});
await expect(sut.search(authStub.admin, {})).resolves.toEqual({
albums: {
total: 0,
count: 0,
page: 1,
items: [],
facets: [],
},
assets: {
total: 0,
count: 0,
page: 1,
items: [],
facets: [],
},
});
expect(searchMock.search.mock.calls).toEqual([
['assets', '*', { userId: authStub.admin.id }],
['albums', '*', { userId: authStub.admin.id }],
]);
});
});
describe('handleIndexAssets', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAssets();
expect(searchMock.import).not.toHaveBeenCalled();
});
it('should index all the assets', async () => {
assetMock.getAll.mockResolvedValue([]);
await sut.handleIndexAssets();
expect(searchMock.import).toHaveBeenCalledWith('assets', [], true);
});
it('should log an error', async () => {
assetMock.getAll.mockResolvedValue([]);
searchMock.import.mockRejectedValue(new Error('import failed'));
await sut.handleIndexAssets();
});
});
describe('handleIndexAsset', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAsset({ asset: assetEntityStub.image });
expect(searchMock.index).not.toHaveBeenCalled();
});
it('should index the asset', async () => {
await sut.handleIndexAsset({ asset: assetEntityStub.image });
expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
});
it('should log an error', async () => {
searchMock.index.mockRejectedValue(new Error('index failed'));
await sut.handleIndexAsset({ asset: assetEntityStub.image });
expect(searchMock.index).toHaveBeenCalled();
});
});
describe('handleIndexAlbums', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAlbums();
expect(searchMock.import).not.toHaveBeenCalled();
});
it('should index all the albums', async () => {
albumMock.getAll.mockResolvedValue([]);
await sut.handleIndexAlbums();
expect(searchMock.import).toHaveBeenCalledWith('albums', [], true);
});
it('should log an error', async () => {
albumMock.getAll.mockResolvedValue([]);
searchMock.import.mockRejectedValue(new Error('import failed'));
await sut.handleIndexAlbums();
});
});
describe('handleIndexAlbum', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAlbum({ album: albumStub.empty });
expect(searchMock.index).not.toHaveBeenCalled();
});
it('should index the album', async () => {
await sut.handleIndexAlbum({ album: albumStub.empty });
expect(searchMock.index).toHaveBeenCalledWith('albums', albumStub.empty);
});
it('should log an error', async () => {
searchMock.index.mockRejectedValue(new Error('index failed'));
await sut.handleIndexAlbum({ album: albumStub.empty });
expect(searchMock.index).toHaveBeenCalled();
});
});
describe('handleRemoveAlbum', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleRemoveAlbum({ id: 'album1' });
expect(searchMock.delete).not.toHaveBeenCalled();
});
it('should remove the album', async () => {
await sut.handleRemoveAlbum({ id: 'album1' });
expect(searchMock.delete).toHaveBeenCalledWith('albums', 'album1');
});
it('should log an error', async () => {
searchMock.delete.mockRejectedValue(new Error('remove failed'));
await sut.handleRemoveAlbum({ id: 'album1' });
expect(searchMock.delete).toHaveBeenCalled();
});
});
describe('handleRemoveAsset', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleRemoveAsset({ id: 'asset1`' });
expect(searchMock.delete).not.toHaveBeenCalled();
});
it('should remove the asset', async () => {
await sut.handleRemoveAsset({ id: 'asset1' });
expect(searchMock.delete).toHaveBeenCalledWith('assets', 'asset1');
});
it('should log an error', async () => {
searchMock.delete.mockRejectedValue(new Error('remove failed'));
await sut.handleRemoveAsset({ id: 'asset1' });
expect(searchMock.delete).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,154 @@
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IAlbumRepository } from '../album/album.repository';
import { IAssetRepository } from '../asset/asset.repository';
import { AuthUserDto } from '../auth';
import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
import { SearchDto } from './dto';
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
import { ISearchRepository, SearchCollection } from './search.repository';
@Injectable()
export class SearchService {
private logger = new Logger(SearchService.name);
private enabled: boolean;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
configService: ConfigService,
) {
this.enabled = configService.get('TYPESENSE_ENABLED') !== 'false';
}
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 });
}
}
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
if (!this.enabled) {
throw new BadRequestException('Search is disabled');
}
const query = dto.query || '*';
return {
assets: (await this.searchRepository.search(SearchCollection.ASSETS, query, {
userId: authUser.id,
...dto,
})) as any,
albums: (await this.searchRepository.search(SearchCollection.ALBUMS, query, {
userId: authUser.id,
...dto,
})) as any,
};
}
async handleIndexAssets() {
if (!this.enabled) {
return;
}
try {
this.logger.debug(`Running indexAssets`);
// TODO: do this in batches based on searchIndexVersion
const assets = await this.assetRepository.getAll({ isVisible: true });
this.logger.log(`Indexing ${assets.length} assets`);
await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
} catch (error: any) {
this.logger.error(`Unable to index all assets`, error?.stack);
}
}
async handleIndexAsset(data: IAssetJob) {
if (!this.enabled) {
return;
}
const { asset } = data;
try {
await this.searchRepository.index(SearchCollection.ASSETS, asset);
} catch (error: any) {
this.logger.error(`Unable to index asset: ${asset.id}`, error?.stack);
}
}
async handleIndexAlbums() {
if (!this.enabled) {
return;
}
try {
const albums = await this.albumRepository.getAll();
this.logger.log(`Indexing ${albums.length} albums`);
await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
} catch (error: any) {
this.logger.error(`Unable to index all albums`, error?.stack);
}
}
async handleIndexAlbum(data: IAlbumJob) {
if (!this.enabled) {
return;
}
const { album } = data;
try {
await this.searchRepository.index(SearchCollection.ALBUMS, album);
} catch (error: any) {
this.logger.error(`Unable to index album: ${album.id}`, error?.stack);
}
}
async handleRemoveAlbum(data: IDeleteJob) {
await this.handleRemove(SearchCollection.ALBUMS, data);
}
async handleRemoveAsset(data: IDeleteJob) {
await this.handleRemove(SearchCollection.ASSETS, data);
}
private async handleRemove(collection: SearchCollection, data: IDeleteJob) {
if (!this.enabled) {
return;
}
const { id } = data;
try {
await this.searchRepository.delete(collection, id);
} catch (error: any) {
this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
}
}
}

View File

@@ -3,5 +3,7 @@ import { IAlbumRepository } from '../src';
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
return {
deleteAll: jest.fn(),
getAll: jest.fn(),
save: jest.fn(),
};
};

View File

@@ -1,4 +1,5 @@
import {
AlbumEntity,
APIKeyEntity,
AssetEntity,
AssetType,
@@ -155,6 +156,21 @@ export const assetEntityStub = {
} as AssetEntity),
};
export const albumStub = {
empty: Object.freeze<AlbumEntity>({
id: 'album-1',
albumName: 'Empty album',
ownerId: authStub.admin.id,
owner: userEntityStub.admin,
assets: [],
albumThumbnailAssetId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
sharedLinks: [],
sharedUsers: [],
}),
};
const assetInfo: ExifResponseDto = {
make: 'camera-make',
model: 'camera-model',

View File

@@ -6,6 +6,7 @@ export * from './device-info.repository.mock';
export * from './fixtures';
export * from './job.repository.mock';
export * from './machine-learning.repository.mock';
export * from './search.repository.mock';
export * from './shared-link.repository.mock';
export * from './smart-info.repository.mock';
export * from './storage.repository.mock';

View File

@@ -0,0 +1,12 @@
import { ISearchRepository } from '../src';
export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
return {
setup: jest.fn(),
checkMigrationStatus: jest.fn(),
index: jest.fn(),
import: jest.fn(),
search: jest.fn(),
delete: jest.fn(),
};
};