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

@@ -2,7 +2,7 @@ import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra';
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service';
@@ -10,6 +10,7 @@ import { ISharedLinkRepository } from '@app/domain';
import {
assetEntityStub,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
userEntityStub,
} from '@app/domain/../test';
@@ -20,6 +21,7 @@ describe('Album service', () => {
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
@@ -139,12 +141,14 @@ describe('Album service', () => {
};
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new AlbumService(
albumRepositoryMock,
sharedLinkRepositoryMock,
downloadServiceMock as DownloadService,
cryptoMock,
jobMock,
);
});
@@ -158,6 +162,7 @@ describe('Album service', () => {
expect(result.id).toEqual(albumEntity.id);
expect(result.albumName).toEqual(albumEntity.albumName);
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } });
});
it('gets list of albums for auth user', async () => {
@@ -291,9 +296,8 @@ describe('Album service', () => {
const updatedAlbumName = 'new album name';
const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac';
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.updateAlbum.mockImplementation(() =>
Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
);
const updatedAlbum = { ...albumEntity, albumName: updatedAlbumName };
albumRepositoryMock.updateAlbum.mockResolvedValue(updatedAlbum);
const result = await sut.updateAlbumInfo(
authUser,
@@ -311,6 +315,7 @@ describe('Album service', () => {
albumName: updatedAlbumName,
albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
});
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } });
});
it('prevents updating a not owned album (shared with auth user)', async () => {

View File

@@ -6,7 +6,7 @@ import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@@ -27,6 +27,7 @@ export class AlbumService {
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
private downloadService: DownloadService,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
}
@@ -56,6 +57,7 @@ export class AlbumService {
async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> {
const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } });
return mapAlbum(albumEntity);
}
@@ -105,6 +107,7 @@ export class AlbumService {
}
await this.albumRepository.delete(album);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { id: albumId } });
}
async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
@@ -171,6 +174,9 @@ export class AlbumService {
}
const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } });
return mapAlbum(updatedAlbum);
}

View File

@@ -252,7 +252,7 @@ export class AssetRepository implements IAssetRepository {
where: {
id: assetId,
},
relations: ['exifInfo', 'tags', 'sharedLinks'],
relations: ['exifInfo', 'tags', 'sharedLinks', 'smartInfo'],
});
}

View File

@@ -445,6 +445,8 @@ describe('AssetService', () => {
]);
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset1' } }],
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset2' } }],
[
{
name: JobName.DELETE_FILES,

View File

@@ -170,6 +170,8 @@ export class AssetService {
const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: updatedAsset } });
return mapAsset(updatedAsset);
}
@@ -425,6 +427,7 @@ export class AssetService {
try {
await this._assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { id } });
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath);

View File

@@ -1,5 +1,5 @@
import { immichAppConfig } from '@app/common/config';
import { Module } from '@nestjs/common';
import { Module, OnModuleInit } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
@@ -9,13 +9,14 @@ import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { JobModule } from './api-v1/job/job.module';
import { TagModule } from './api-v1/tag/tag.module';
import { DomainModule } from '@app/domain';
import { DomainModule, SearchService } from '@app/domain';
import { InfraModule } from '@app/infra';
import {
APIKeyController,
AuthController,
DeviceInfoController,
OAuthController,
SearchController,
ShareController,
SystemConfigController,
UserController,
@@ -46,16 +47,21 @@ import { AuthGuard } from './middlewares/auth.guard';
TagModule,
],
controllers: [
//
AppController,
APIKeyController,
AuthController,
DeviceInfoController,
OAuthController,
SearchController,
ShareController,
SystemConfigController,
UserController,
],
providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
})
export class AppModule {}
export class AppModule implements OnModuleInit {
constructor(private searchService: SearchService) {}
async onModuleInit() {
await this.searchService.bootstrap();
}
}

View File

@@ -2,6 +2,7 @@ export * from './api-key.controller';
export * from './auth.controller';
export * from './device-info.controller';
export * from './oauth.controller';
export * from './search.controller';
export * from './share.controller';
export * from './system-config.controller';
export * from './user.controller';

View File

@@ -0,0 +1,27 @@
import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain';
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
@ApiTags('Search')
@Authenticated()
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Authenticated()
@Get()
async search(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: SearchDto,
): Promise<SearchResponseDto> {
return this.searchService.search(authUser, dto);
}
@Authenticated()
@Get('config')
getSearchConfig(): SearchConfigResponseDto {
return this.searchService.getConfig();
}
}

View File

@@ -11,7 +11,7 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
import { json } from 'body-parser';
import { patchOpenAPI } from './utils/patch-open-api.util';
import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
import { IMMICH_ACCESS_COOKIE } from '@app/domain';
import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
const logger = new Logger('ImmichServer');
@@ -73,6 +73,9 @@ async function bootstrap() {
);
});
const searchService = app.get(SearchService);
logger.warn(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`);
logger.warn(`Search is ${searchService.isEnabled() ? 'enabled' : 'disabled'}`);
}
bootstrap();