feat: Search filtering logic (#6968)

* commit

* controller/service/repository logic

* use enum

* openapi

* suggest people

* suggest place/camera

* cursor hover

* refactor

* Add try catch

* Remove get people with name service

* Remove deadcode

* people selection

* People placement

* sort people

* Update server/src/domain/repositories/metadata.repository.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* pr feedback

* styling

* done

* open api

* fix test

* use string type

* remmove bad merge

* use correct type

* fix test

* fix lint

* remove unused code

* remove unused code

* pr feedback

* pr feedback

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Alex 2024-02-13 13:54:58 -06:00 committed by GitHub
parent 0c45f51a29
commit 4b3f8d1946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1145 additions and 118 deletions

View file

@ -39,4 +39,9 @@ export interface IMetadataRepository {
readTags(path: string): Promise<ImmichTags | null>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
getCountries(userId: string): Promise<string[]>;
getStates(userId: string, country?: string): Promise<string[]>;
getCities(userId: string, country?: string, state?: string): Promise<string[]>;
getCameraMakes(userId: string, model?: string): Promise<string[]>;
getCameraModels(userId: string, make?: string): Promise<string[]>;
}

View file

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export enum SearchSuggestionType {
COUNTRY = 'country',
STATE = 'state',
CITY = 'city',
CAMERA_MAKE = 'camera-make',
CAMERA_MODEL = 'camera-model',
}
export class SearchSuggestionRequestDto {
@IsEnum(SearchSuggestionType)
@IsNotEmpty()
@ApiProperty({ enumName: 'SearchSuggestionType', enum: SearchSuggestionType })
type!: SearchSuggestionType;
@IsString()
@IsOptional()
country?: string;
@IsString()
@IsOptional()
state?: string;
@IsString()
@IsOptional()
make?: string;
@IsString()
@IsOptional()
model?: string;
}

View file

@ -4,6 +4,7 @@ import {
authStub,
newAssetRepositoryMock,
newMachineLearningRepositoryMock,
newMetadataRepositoryMock,
newPartnerRepositoryMock,
newPersonRepositoryMock,
newSearchRepositoryMock,
@ -14,6 +15,7 @@ import { mapAsset } from '../asset';
import {
IAssetRepository,
IMachineLearningRepository,
IMetadataRepository,
IPartnerRepository,
IPersonRepository,
ISearchRepository,
@ -32,6 +34,7 @@ describe(SearchService.name, () => {
let personMock: jest.Mocked<IPersonRepository>;
let searchMock: jest.Mocked<ISearchRepository>;
let partnerMock: jest.Mocked<IPartnerRepository>;
let metadataMock: jest.Mocked<IMetadataRepository>;
beforeEach(() => {
assetMock = newAssetRepositoryMock();
@ -40,7 +43,9 @@ describe(SearchService.name, () => {
personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock();
partnerMock = newPartnerRepositoryMock();
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock);
metadataMock = newMetadataRepositoryMock();
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock, metadataMock);
});
it('should work', () => {

View file

@ -7,6 +7,7 @@ import { PersonResponseDto } from '../person';
import {
IAssetRepository,
IMachineLearningRepository,
IMetadataRepository,
IPartnerRepository,
IPersonRepository,
ISearchRepository,
@ -16,6 +17,7 @@ import {
} from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config';
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
import { SearchResponseDto } from './response-dto';
@Injectable()
@ -30,6 +32,7 @@ export class SearchService {
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
}
@ -176,4 +179,28 @@ export class SearchService {
},
};
}
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
if (dto.type === SearchSuggestionType.COUNTRY) {
return this.metadataRepository.getCountries(auth.user.id);
}
if (dto.type === SearchSuggestionType.STATE) {
return this.metadataRepository.getStates(auth.user.id, dto.country);
}
if (dto.type === SearchSuggestionType.CITY) {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
}
if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
}
if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
}
return [];
}
}

View file

@ -9,6 +9,7 @@ import {
SearchService,
SmartSearchDto,
} from '@app/domain';
import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard';
@ -46,4 +47,9 @@ export class SearchController {
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.service.searchPerson(auth, dto);
}
@Get('suggestions')
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
return this.service.getSearchSuggestions(auth, dto);
}
}

View file

@ -10,7 +10,13 @@ import {
ISystemMetadataRepository,
ReverseGeocodeResult,
} from '@app/domain';
import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import {
ExifEntity,
GeodataAdmin1Entity,
GeodataAdmin2Entity,
GeodataPlacesEntity,
SystemMetadataKey,
} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@ -21,12 +27,14 @@ import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline';
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
import { DummyValue, GenerateSql } from '../infra.util';
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
export class MetadataRepository implements IMetadataRepository {
constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>,
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>,
@ -213,4 +221,106 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}
}
@GenerateSql({ params: [DummyValue.UUID] })
async getCountries(userId: string): Promise<string[]> {
const entity = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.country IS NOT NULL')
.select('exif.country')
.distinctOn(['exif.country'])
.getMany();
return entity.map((e) => e.country ?? '').filter((c) => c !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getStates(userId: string, country: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.state IS NOT NULL')
.select('exif.state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
result = await query.getMany();
return result.map((entity) => entity.state ?? '').filter((s) => s !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.city IS NOT NULL')
.select('exif.city')
.distinctOn(['exif.city']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
if (state) {
query.andWhere('exif.state = :state', { state });
}
result = await query.getMany();
return result.map((entity) => entity.city ?? '').filter((c) => c !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.make IS NOT NULL')
.select('exif.make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
result = await query.getMany();
return result.map((entity) => entity.make ?? '').filter((m) => m !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.model IS NOT NULL')
.select('exif.model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
result = await query.getMany();
return result.map((entity) => entity.model ?? '').filter((m) => m !== '');
}
}