mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
0c45f51a29
commit
4b3f8d1946
24 changed files with 1145 additions and 118 deletions
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
33
server/src/domain/search/dto/search-suggestion.dto.ts
Normal file
33
server/src/domain/search/dto/search-suggestion.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 !== '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue