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();

View File

@@ -7,6 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import {
BackgroundTaskProcessor,
MachineLearningProcessor,
SearchIndexProcessor,
StorageTemplateMigrationProcessor,
ThumbnailGeneratorProcessor,
} from './processors';
@@ -26,6 +27,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
MachineLearningProcessor,
StorageTemplateMigrationProcessor,
BackgroundTaskProcessor,
SearchIndexProcessor,
],
})
export class MicroservicesModule {}

View File

@@ -1,12 +1,15 @@
import {
AssetService,
IAlbumJob,
IAssetJob,
IAssetUploadedJob,
IDeleteFilesJob,
IDeleteJob,
IUserDeletionJob,
JobName,
MediaService,
QueueName,
SearchService,
SmartInfoService,
StorageService,
StorageTemplateService,
@@ -61,6 +64,41 @@ export class MachineLearningProcessor {
}
}
@Processor(QueueName.SEARCH)
export class SearchIndexProcessor {
constructor(private searchService: SearchService) {}
@Process(JobName.SEARCH_INDEX_ALBUMS)
async onIndexAlbums() {
await this.searchService.handleIndexAlbums();
}
@Process(JobName.SEARCH_INDEX_ASSETS)
async onIndexAssets() {
await this.searchService.handleIndexAssets();
}
@Process(JobName.SEARCH_INDEX_ALBUM)
async onIndexAlbum(job: Job<IAlbumJob>) {
await this.searchService.handleIndexAlbum(job.data);
}
@Process(JobName.SEARCH_INDEX_ASSET)
async onIndexAsset(job: Job<IAssetJob>) {
await this.searchService.handleIndexAsset(job.data);
}
@Process(JobName.SEARCH_REMOVE_ALBUM)
async onRemoveAlbum(job: Job<IDeleteJob>) {
await this.searchService.handleRemoveAlbum(job.data);
}
@Process(JobName.SEARCH_REMOVE_ASSET)
async onRemoveAsset(job: Job<IDeleteJob>) {
await this.searchService.handleRemoveAsset(job.data);
}
}
@Processor(QueueName.STORAGE_TEMPLATE_MIGRATION)
export class StorageTemplateMigrationProcessor {
constructor(private storageTemplateService: StorageTemplateService) {}

View File

@@ -1,18 +1,26 @@
import {
AssetCore,
IAssetRepository,
IAssetUploadedJob,
IReverseGeocodingJob,
ISearchRepository,
JobName,
QueueName,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain';
import { Process, Processor } from '@nestjs/bull';
import { Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
import ffmpeg from 'fluent-ffmpeg';
import { getName } from 'i18n-iso-countries';
import geocoder, { InitOptions } from 'local-reverse-geocoder';
import fs from 'node:fs';
import path from 'path';
import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository';
import geocoder, { InitOptions } from 'local-reverse-geocoder';
import { getName } from 'i18n-iso-countries';
import fs from 'node:fs';
import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
interface ImmichTags extends Tags {
ContentIdentifier?: string;
@@ -71,13 +79,19 @@ export type GeoData = {
export class MetadataExtractionProcessor {
private logger = new Logger(MetadataExtractionProcessor.name);
private isGeocodeInitialized = false;
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(ISearchRepository) searchRepository: ISearchRepository,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
configService: ConfigService,
) {
this.assetCore = new AssetCore(assetRepository, searchRepository);
if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
this.logger.log('Initializing Reverse Geocoding');
geocoderInit({
@@ -175,20 +189,11 @@ export class MetadataExtractionProcessor {
newExif.longitude = exifData?.GPSLongitude || null;
newExif.livePhotoCID = exifData?.MediaGroupUUID || null;
await this.assetRepository.save({
id: asset.id,
fileCreatedAt: fileCreatedAt?.toISOString(),
});
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
const motionAsset = await this.assetRepository.findLivePhotoMatch(
newExif.livePhotoCID,
asset.id,
AssetType.VIDEO,
);
const motionAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.VIDEO);
if (motionAsset) {
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
await this.assetCore.save({ id: motionAsset.id, isVisible: false });
}
}
@@ -226,6 +231,7 @@ export class MetadataExtractionProcessor {
}
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() });
} catch (error: any) {
this.logger.error(`Error extracting EXIF ${error}`, error?.stack);
}
@@ -292,14 +298,10 @@ export class MetadataExtractionProcessor {
newExif.livePhotoCID = exifData?.ContentIdentifier || null;
if (newExif.livePhotoCID) {
const photoAsset = await this.assetRepository.findLivePhotoMatch(
newExif.livePhotoCID,
asset.id,
AssetType.IMAGE,
);
const photoAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.IMAGE);
if (photoAsset) {
await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
await this.assetRepository.save({ id: asset.id, isVisible: false });
await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
await this.assetCore.save({ id: asset.id, isVisible: false });
}
}
@@ -355,7 +357,7 @@ export class MetadataExtractionProcessor {
}
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
await this.assetCore.save({ id: asset.id, duration: durationString, fileCreatedAt });
} catch (err) {
``;
// do nothing