feat(server, web): smart search filtering and pagination (#6525)
* initial pagination impl * use limit + offset instead of take + skip * wip web pagination * working infinite scroll * update api * formatting * fix rebase * search refactor * re-add runtime config for vector search * fix rebase * fixes * useless omitBy * unnecessary handling * add sql decorator for `searchAssets` * fixed search builder * fixed sql * remove mock method * linting * fixed pagination * fixed unit tests * formatting * fix e2e tests * re-flatten search builder * refactor endpoints * clean up dto * refinements * don't break everything just yet * update openapi spec & sql * update api * linting * update sql * fixes * optimize web code * fix typing * add page limit * make limit based on asset count * increase limit * simpler import
This commit is contained in:
@@ -31,8 +31,6 @@ import {
|
||||
AssetBulkUpdateDto,
|
||||
AssetJobName,
|
||||
AssetJobsDto,
|
||||
AssetOrder,
|
||||
AssetSearchDto,
|
||||
AssetStatsDto,
|
||||
MapMarkerDto,
|
||||
MemoryLaneDto,
|
||||
@@ -92,34 +90,6 @@ export class AssetService {
|
||||
this.configCore = SystemConfigCore.create(configRepository);
|
||||
}
|
||||
|
||||
search(auth: AuthDto, dto: AssetSearchDto) {
|
||||
let checksum: Buffer | undefined;
|
||||
|
||||
if (dto.checksum) {
|
||||
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
|
||||
checksum = Buffer.from(dto.checksum, encoding);
|
||||
}
|
||||
|
||||
const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const;
|
||||
const order = dto.order ? enumToOrder[dto.order] : undefined;
|
||||
|
||||
return this.assetRepository
|
||||
.search({
|
||||
...dto,
|
||||
order,
|
||||
checksum,
|
||||
ownerId: auth.user.id,
|
||||
})
|
||||
.then((assets) =>
|
||||
assets.map((asset) =>
|
||||
mapAsset(asset, {
|
||||
stripMetadata: false,
|
||||
withStack: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsNotEmpty,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util';
|
||||
import { Optional, ValidateUUID } from '../../domain.util';
|
||||
import { BulkIdsDto } from '../response-dto';
|
||||
|
||||
export class DeviceIdDto {
|
||||
@@ -32,152 +28,6 @@ const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
|
||||
o.latitude !== undefined || o.longitude !== undefined;
|
||||
const ValidateGPS = () => ValidateIf(hasGPS);
|
||||
|
||||
export class AssetSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
id?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
libraryId?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
deviceAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
deviceId?: string;
|
||||
|
||||
@IsEnum(AssetType)
|
||||
@Optional()
|
||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||
type?: AssetType;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
checksum?: string;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isArchived?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isEncoded?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isExternal?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isMotion?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isOffline?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isReadOnly?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withDeleted?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withStacked?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withExif?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withPeople?: boolean;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
createdBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
createdAfter?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
updatedBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
updatedAfter?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
trashedBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
trashedAfter?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
takenBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
takenAfter?: Date;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
originalFileName?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
originalPath?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
resizePath?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
webpPath?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
encodedVideoPath?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
city?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
state?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
country?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
make?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
model?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
lensModel?: string;
|
||||
|
||||
@IsEnum(AssetOrder)
|
||||
@Optional()
|
||||
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||
order?: AssetOrder;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@Optional()
|
||||
page?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@Optional()
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export class AssetBulkUpdateDto extends BulkIdsDto {
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -137,6 +137,17 @@ export interface PaginationOptions {
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
export enum PaginationMode {
|
||||
LIMIT_OFFSET = 'limit-offset',
|
||||
SKIP_TAKE = 'skip-take',
|
||||
}
|
||||
|
||||
export interface PaginatedBuilderOptions {
|
||||
take: number;
|
||||
skip?: number;
|
||||
mode?: PaginationMode;
|
||||
}
|
||||
|
||||
export interface PaginationResult<T> {
|
||||
items: T[];
|
||||
hasNextPage: boolean;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
newMediaRepositoryMock,
|
||||
newMoveRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newSmartInfoRepositoryMock,
|
||||
newSearchRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
personStub,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
IMediaRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
ISmartInfoRepository,
|
||||
ISearchRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
WithoutProperty,
|
||||
@@ -76,7 +76,7 @@ describe(PersonService.name, () => {
|
||||
let moveMock: jest.Mocked<IMoveRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
||||
let searchMock: jest.Mocked<ISearchRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let sut: PersonService;
|
||||
|
||||
@@ -90,7 +90,7 @@ describe(PersonService.name, () => {
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
smartInfoMock = newSmartInfoRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
sut = new PersonService(
|
||||
accessMock,
|
||||
@@ -102,7 +102,7 @@ describe(PersonService.name, () => {
|
||||
configMock,
|
||||
storageMock,
|
||||
jobMock,
|
||||
smartInfoMock,
|
||||
searchMock,
|
||||
cryptoMock,
|
||||
);
|
||||
|
||||
@@ -752,7 +752,7 @@ describe(PersonService.name, () => {
|
||||
it('should create a face with no person and queue recognition job', async () => {
|
||||
personMock.createFaces.mockResolvedValue([faceStub.face1.id]);
|
||||
machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]);
|
||||
smartInfoMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]);
|
||||
searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const face = {
|
||||
assetId: 'asset-id',
|
||||
@@ -823,7 +823,7 @@ describe(PersonService.name, () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
|
||||
]);
|
||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||
|
||||
@@ -850,7 +850,7 @@ describe(PersonService.name, () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
|
||||
]);
|
||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
@@ -869,14 +869,14 @@ describe(PersonService.name, () => {
|
||||
it('should not queue face with no matches', async () => {
|
||||
const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
||||
|
||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(1);
|
||||
expect(searchMock.searchFaces).toHaveBeenCalledTimes(1);
|
||||
expect(personMock.create).not.toHaveBeenCalled();
|
||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -890,7 +890,7 @@ describe(PersonService.name, () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
|
||||
]);
|
||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
@@ -900,7 +900,7 @@ describe(PersonService.name, () => {
|
||||
name: JobName.FACIAL_RECOGNITION,
|
||||
data: { id: faceStub.noPerson1.id, deferred: true },
|
||||
});
|
||||
expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(1);
|
||||
expect(searchMock.searchFaces).toHaveBeenCalledTimes(1);
|
||||
expect(personMock.create).not.toHaveBeenCalled();
|
||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -914,14 +914,14 @@ describe(PersonService.name, () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
|
||||
]);
|
||||
smartInfoMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||
searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(2);
|
||||
expect(searchMock.searchFaces).toHaveBeenCalledTimes(2);
|
||||
expect(personMock.create).not.toHaveBeenCalled();
|
||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
IMediaRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
ISmartInfoRepository,
|
||||
ISearchRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
JobItem,
|
||||
@@ -61,7 +61,7 @@ export class PersonService {
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
||||
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
) {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
@@ -285,15 +285,7 @@ export class PersonService {
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination, {
|
||||
order: 'DESC',
|
||||
withFaces: true,
|
||||
withPeople: false,
|
||||
withSmartInfo: false,
|
||||
withSmartSearch: false,
|
||||
withExif: false,
|
||||
withStacked: false,
|
||||
})
|
||||
? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true })
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SearchExploreItem } from '@app/domain';
|
||||
import { AssetSearchOptions, SearchExploreItem } from '@app/domain';
|
||||
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
|
||||
import { Paginated, PaginationOptions } from '../domain.util';
|
||||
@@ -11,64 +11,6 @@ export interface AssetStatsOptions {
|
||||
isTrashed?: boolean;
|
||||
}
|
||||
|
||||
export interface AssetSearchOptions {
|
||||
id?: string;
|
||||
libraryId?: string;
|
||||
deviceAssetId?: string;
|
||||
deviceId?: string;
|
||||
ownerId?: string;
|
||||
type?: AssetType;
|
||||
checksum?: Buffer;
|
||||
|
||||
isArchived?: boolean;
|
||||
isEncoded?: boolean;
|
||||
isExternal?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isMotion?: boolean;
|
||||
isOffline?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isVisible?: boolean;
|
||||
|
||||
withDeleted?: boolean;
|
||||
withStacked?: boolean;
|
||||
withExif?: boolean;
|
||||
withPeople?: boolean;
|
||||
withSmartInfo?: boolean;
|
||||
withSmartSearch?: boolean;
|
||||
withFaces?: boolean;
|
||||
|
||||
createdBefore?: Date;
|
||||
createdAfter?: Date;
|
||||
updatedBefore?: Date;
|
||||
updatedAfter?: Date;
|
||||
trashedBefore?: Date;
|
||||
trashedAfter?: Date;
|
||||
takenBefore?: Date;
|
||||
takenAfter?: Date;
|
||||
|
||||
originalFileName?: string;
|
||||
originalPath?: string;
|
||||
resizePath?: string;
|
||||
webpPath?: string;
|
||||
encodedVideoPath?: string;
|
||||
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
lensModel?: string;
|
||||
|
||||
/** defaults to 'DESC' */
|
||||
order?: 'ASC' | 'DESC';
|
||||
|
||||
/** defaults to 1 */
|
||||
page?: number;
|
||||
|
||||
/** defaults to 250 */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface LivePhotoSearchOptions {
|
||||
ownerId: string;
|
||||
livePhotoCID: string;
|
||||
@@ -204,7 +146,6 @@ export interface IAssetRepository {
|
||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
|
||||
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
|
||||
upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void>;
|
||||
search(options: AssetSearchOptions): Promise<AssetEntity[]>;
|
||||
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||
searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise<AssetEntity[]>;
|
||||
|
||||
@@ -19,7 +19,6 @@ export * from './person.repository';
|
||||
export * from './search.repository';
|
||||
export * from './server-info.repository';
|
||||
export * from './shared-link.repository';
|
||||
export * from './smart-info.repository';
|
||||
export * from './storage.repository';
|
||||
export * from './system-config.repository';
|
||||
export * from './system-metadata.repository';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { AssetType } from '@app/infra/entities';
|
||||
import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities';
|
||||
import { Paginated } from '../domain.util';
|
||||
|
||||
export const ISearchRepository = 'ISearchRepository';
|
||||
|
||||
export enum SearchStrategy {
|
||||
SMART = 'SMART',
|
||||
@@ -54,3 +57,122 @@ export interface SearchExploreItem<T> {
|
||||
fieldName: string;
|
||||
items: SearchExploreItemSet<T>;
|
||||
}
|
||||
|
||||
export type Embedding = number[];
|
||||
|
||||
export interface SearchAssetIDOptions {
|
||||
checksum?: Buffer;
|
||||
deviceAssetId?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SearchUserIDOptions {
|
||||
deviceId?: string;
|
||||
libraryId?: string;
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
export type SearchIDOptions = SearchAssetIDOptions & SearchUserIDOptions;
|
||||
|
||||
export interface SearchStatusOptions {
|
||||
isArchived?: boolean;
|
||||
isEncoded?: boolean;
|
||||
isExternal?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isMotion?: boolean;
|
||||
isOffline?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isVisible?: boolean;
|
||||
type?: AssetType;
|
||||
withArchived?: boolean;
|
||||
withDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOneToOneRelationOptions {
|
||||
withExif?: boolean;
|
||||
withSmartInfo?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchRelationOptions extends SearchOneToOneRelationOptions {
|
||||
withFaces?: boolean;
|
||||
withPeople?: boolean;
|
||||
withStacked?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchDateOptions {
|
||||
createdBefore?: Date;
|
||||
createdAfter?: Date;
|
||||
takenBefore?: Date;
|
||||
takenAfter?: Date;
|
||||
trashedBefore?: Date;
|
||||
trashedAfter?: Date;
|
||||
updatedBefore?: Date;
|
||||
updatedAfter?: Date;
|
||||
}
|
||||
|
||||
export interface SearchPathOptions {
|
||||
encodedVideoPath?: string;
|
||||
originalFileName?: string;
|
||||
originalPath?: string;
|
||||
resizePath?: string;
|
||||
webpPath?: string;
|
||||
}
|
||||
|
||||
export interface SearchExifOptions {
|
||||
city?: string;
|
||||
country?: string;
|
||||
lensModel?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface SearchEmbeddingOptions {
|
||||
embedding: Embedding;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
export interface SearchOrderOptions {
|
||||
orderDirection?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface SearchPaginationOptions {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type AssetSearchOptions = SearchDateOptions &
|
||||
SearchIDOptions &
|
||||
SearchExifOptions &
|
||||
SearchOrderOptions &
|
||||
SearchPathOptions &
|
||||
SearchRelationOptions &
|
||||
SearchStatusOptions;
|
||||
|
||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||
|
||||
export type SmartSearchOptions = SearchDateOptions &
|
||||
SearchEmbeddingOptions &
|
||||
SearchExifOptions &
|
||||
SearchOneToOneRelationOptions &
|
||||
SearchStatusOptions &
|
||||
SearchUserIDOptions;
|
||||
|
||||
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
||||
hasPerson?: boolean;
|
||||
numResults: number;
|
||||
maxDistance?: number;
|
||||
}
|
||||
|
||||
export interface FaceSearchResult {
|
||||
distance: number;
|
||||
face: AssetFaceEntity;
|
||||
}
|
||||
|
||||
export interface ISearchRepository {
|
||||
init(modelName: string): Promise<void>;
|
||||
searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
|
||||
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { AssetEntity, AssetFaceEntity, SmartInfoEntity } from '@app/infra/entities';
|
||||
|
||||
export const ISmartInfoRepository = 'ISmartInfoRepository';
|
||||
|
||||
export type Embedding = number[];
|
||||
|
||||
export interface EmbeddingSearch {
|
||||
userIds: string[];
|
||||
embedding: Embedding;
|
||||
numResults: number;
|
||||
withArchived?: boolean;
|
||||
}
|
||||
|
||||
export interface FaceEmbeddingSearch extends EmbeddingSearch {
|
||||
maxDistance?: number;
|
||||
hasPerson?: boolean;
|
||||
}
|
||||
|
||||
export interface FaceSearchResult {
|
||||
face: AssetFaceEntity;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export interface ISmartInfoRepository {
|
||||
init(modelName: string): Promise<void>;
|
||||
searchCLIP(search: EmbeddingSearch): Promise<AssetEntity[]>;
|
||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
||||
}
|
||||
@@ -1,8 +1,184 @@
|
||||
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
|
||||
import { AssetType } from '@app/infra/entities';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Optional, toBoolean } from '../../domain.util';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||
import { Optional, QueryBoolean, QueryDate, ValidateUUID, toBoolean } from '../../domain.util';
|
||||
|
||||
class BaseSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
libraryId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
deviceId?: string;
|
||||
|
||||
@IsEnum(AssetType)
|
||||
@Optional()
|
||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||
type?: AssetType;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isArchived?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withArchived?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isEncoded?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isExternal?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isMotion?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isOffline?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isReadOnly?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withDeleted?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withExif?: boolean;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
createdBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
createdAfter?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
updatedBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
updatedAfter?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
trashedBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
trashedAfter?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
takenBefore?: Date;
|
||||
|
||||
@QueryDate({ optional: true })
|
||||
takenAfter?: Date;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
city?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
state?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
country?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
make?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
model?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
lensModel?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@Optional()
|
||||
page?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
@Type(() => Number)
|
||||
@Optional()
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export class MetadataSearchDto extends BaseSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
deviceAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
checksum?: string;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withStacked?: boolean;
|
||||
|
||||
@QueryBoolean({ optional: true })
|
||||
withPeople?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
originalFileName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
originalPath?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
resizePath?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
webpPath?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
encodedVideoPath?: string;
|
||||
|
||||
@IsEnum(AssetOrder)
|
||||
@Optional()
|
||||
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
export class SmartSearchDto extends BaseSearchDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
query!: string;
|
||||
}
|
||||
|
||||
// TODO: remove after implementing new search filters
|
||||
/** @deprecated */
|
||||
export class SearchDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -43,6 +219,19 @@ export class SearchDto {
|
||||
@Optional()
|
||||
@Transform(toBoolean)
|
||||
withArchived?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@Optional()
|
||||
page?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
@Type(() => Number)
|
||||
@Optional()
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export class SearchPeopleDto {
|
||||
|
||||
@@ -29,6 +29,7 @@ class SearchAssetResponseDto {
|
||||
count!: number;
|
||||
items!: AssetResponseDto[];
|
||||
facets!: SearchFacetResponseDto[];
|
||||
nextPage!: string | null;
|
||||
}
|
||||
|
||||
export class SearchResponseDto {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
newMachineLearningRepositoryMock,
|
||||
newPartnerRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newSmartInfoRepositoryMock,
|
||||
newSearchRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
personStub,
|
||||
} from '@test';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
IMachineLearningRepository,
|
||||
IPartnerRepository,
|
||||
IPersonRepository,
|
||||
ISmartInfoRepository,
|
||||
ISearchRepository,
|
||||
ISystemConfigRepository,
|
||||
} from '../repositories';
|
||||
import { SearchDto } from './dto';
|
||||
@@ -30,7 +30,7 @@ describe(SearchService.name, () => {
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
||||
let searchMock: jest.Mocked<ISearchRepository>;
|
||||
let partnerMock: jest.Mocked<IPartnerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,9 +38,9 @@ describe(SearchService.name, () => {
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
machineMock = newMachineLearningRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
smartInfoMock = newSmartInfoRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
partnerMock = newPartnerRepositoryMock();
|
||||
sut = new SearchService(configMock, machineMock, personMock, smartInfoMock, assetMock, partnerMock);
|
||||
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -104,6 +104,7 @@ describe(SearchService.name, () => {
|
||||
count: 1,
|
||||
items: [mapAsset(assetStub.image)],
|
||||
facets: [],
|
||||
nextPage: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,13 +112,13 @@ describe(SearchService.name, () => {
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, [authStub.user1.user.id], { numResults: 250 });
|
||||
expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled();
|
||||
expect(searchMock.searchSmart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search archived photos if `withArchived` option is true', async () => {
|
||||
const dto: SearchDto = { q: 'test query', clip: true, withArchived: true };
|
||||
const embedding = [1, 2, 3];
|
||||
smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]);
|
||||
searchMock.searchSmart.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false });
|
||||
machineMock.encodeText.mockResolvedValueOnce(embedding);
|
||||
partnerMock.getAll.mockResolvedValueOnce([]);
|
||||
const expectedResponse = {
|
||||
@@ -132,25 +133,28 @@ describe(SearchService.name, () => {
|
||||
count: 1,
|
||||
items: [mapAsset(assetStub.image)],
|
||||
facets: [],
|
||||
nextPage: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sut.search(authStub.user1, dto);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({
|
||||
userIds: [authStub.user1.user.id],
|
||||
embedding,
|
||||
numResults: 100,
|
||||
withArchived: true,
|
||||
});
|
||||
expect(searchMock.searchSmart).toHaveBeenCalledWith(
|
||||
{ page: 1, size: 100 },
|
||||
{
|
||||
userIds: [authStub.user1.user.id],
|
||||
embedding,
|
||||
withArchived: true,
|
||||
},
|
||||
);
|
||||
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search by CLIP if `clip` option is true', async () => {
|
||||
const dto: SearchDto = { q: 'test query', clip: true };
|
||||
const embedding = [1, 2, 3];
|
||||
smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]);
|
||||
searchMock.searchSmart.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false });
|
||||
machineMock.encodeText.mockResolvedValueOnce(embedding);
|
||||
partnerMock.getAll.mockResolvedValueOnce([]);
|
||||
const expectedResponse = {
|
||||
@@ -165,18 +169,21 @@ describe(SearchService.name, () => {
|
||||
count: 1,
|
||||
items: [mapAsset(assetStub.image)],
|
||||
facets: [],
|
||||
nextPage: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sut.search(authStub.user1, dto);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({
|
||||
userIds: [authStub.user1.user.id],
|
||||
embedding,
|
||||
numResults: 100,
|
||||
withArchived: false,
|
||||
});
|
||||
expect(searchMock.searchSmart).toHaveBeenCalledWith(
|
||||
{ page: 1, size: 100 },
|
||||
{
|
||||
userIds: [authStub.user1.user.id],
|
||||
embedding,
|
||||
withArchived: false,
|
||||
},
|
||||
);
|
||||
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AssetEntity } from '@app/infra/entities';
|
||||
import { ImmichLogger } from '@app/infra/logger';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AssetOrder, AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AuthDto } from '../auth';
|
||||
import { PersonResponseDto } from '../person';
|
||||
import {
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
IMachineLearningRepository,
|
||||
IPartnerRepository,
|
||||
IPersonRepository,
|
||||
ISmartInfoRepository,
|
||||
ISearchRepository,
|
||||
ISystemConfigRepository,
|
||||
SearchExploreItem,
|
||||
SearchStrategy,
|
||||
} from '../repositories';
|
||||
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
||||
import { SearchDto, SearchPeopleDto } from './dto';
|
||||
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
|
||||
import { SearchResponseDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -27,7 +27,7 @@ export class SearchService {
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
||||
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||
) {
|
||||
@@ -55,6 +55,53 @@ export class SearchService {
|
||||
}));
|
||||
}
|
||||
|
||||
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||
let checksum: Buffer | undefined;
|
||||
|
||||
if (dto.checksum) {
|
||||
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
|
||||
checksum = Buffer.from(dto.checksum, encoding);
|
||||
}
|
||||
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 250;
|
||||
const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||
{ page, size },
|
||||
{
|
||||
...dto,
|
||||
checksum,
|
||||
ownerId: auth.user.id,
|
||||
orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC',
|
||||
},
|
||||
);
|
||||
|
||||
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null);
|
||||
}
|
||||
|
||||
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
|
||||
await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH);
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
|
||||
const embedding = await this.machineLearning.encodeText(
|
||||
machineLearning.url,
|
||||
{ text: dto.query },
|
||||
machineLearning.clip,
|
||||
);
|
||||
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 100;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||
{ page, size },
|
||||
{ ...dto, userIds, embedding },
|
||||
);
|
||||
|
||||
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null);
|
||||
}
|
||||
|
||||
// TODO: remove after implementing new search filters
|
||||
/** @deprecated */
|
||||
async search(auth: AuthDto, dto: SearchDto): Promise<SearchResponseDto> {
|
||||
await this.configCore.requireFeature(FeatureFlag.SEARCH);
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
@@ -70,10 +117,10 @@ export class SearchService {
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const withArchived = dto.withArchived || false;
|
||||
const page = dto.page ?? 1;
|
||||
|
||||
let nextPage: string | null = null;
|
||||
let assets: AssetEntity[] = [];
|
||||
|
||||
switch (strategy) {
|
||||
case SearchStrategy.SMART: {
|
||||
const embedding = await this.machineLearning.encodeText(
|
||||
@@ -81,36 +128,30 @@ export class SearchService {
|
||||
{ text: query },
|
||||
machineLearning.clip,
|
||||
);
|
||||
assets = await this.smartInfoRepository.searchCLIP({
|
||||
userIds: userIds,
|
||||
embedding,
|
||||
numResults: 100,
|
||||
withArchived,
|
||||
});
|
||||
|
||||
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||
{ page, size: dto.size || 100 },
|
||||
{
|
||||
userIds,
|
||||
embedding,
|
||||
withArchived: !!dto.withArchived,
|
||||
},
|
||||
);
|
||||
if (hasNextPage) {
|
||||
nextPage = (page + 1).toString();
|
||||
}
|
||||
assets = items;
|
||||
break;
|
||||
}
|
||||
case SearchStrategy.TEXT: {
|
||||
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 });
|
||||
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: dto.size || 250 });
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
albums: {
|
||||
total: 0,
|
||||
count: 0,
|
||||
items: [],
|
||||
facets: [],
|
||||
},
|
||||
assets: {
|
||||
total: assets.length,
|
||||
count: assets.length,
|
||||
items: assets.map((asset) => mapAsset(asset)),
|
||||
facets: [],
|
||||
},
|
||||
};
|
||||
return this.mapResponse(assets, nextPage);
|
||||
}
|
||||
|
||||
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
|
||||
@@ -122,4 +163,17 @@ export class SearchService {
|
||||
userIds.push(...partnersIds);
|
||||
return userIds;
|
||||
}
|
||||
|
||||
private async mapResponse(assets: AssetEntity[], nextPage: string | null): Promise<SearchResponseDto> {
|
||||
return {
|
||||
albums: { total: 0, count: 0, items: [], facets: [] },
|
||||
assets: {
|
||||
total: assets.length,
|
||||
count: assets.length,
|
||||
items: assets.map((asset) => mapAsset(asset)),
|
||||
facets: [],
|
||||
nextPage,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
newDatabaseRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMachineLearningRepositoryMock,
|
||||
newSmartInfoRepositoryMock,
|
||||
newSearchRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
} from '@test';
|
||||
import { JobName } from '../job';
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
IDatabaseRepository,
|
||||
IJobRepository,
|
||||
IMachineLearningRepository,
|
||||
ISmartInfoRepository,
|
||||
ISearchRepository,
|
||||
ISystemConfigRepository,
|
||||
WithoutProperty,
|
||||
} from '../repositories';
|
||||
@@ -31,18 +31,18 @@ describe(SmartInfoService.name, () => {
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let smartMock: jest.Mocked<ISmartInfoRepository>;
|
||||
let searchMock: jest.Mocked<ISearchRepository>;
|
||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||
let databaseMock: jest.Mocked<IDatabaseRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
smartMock = newSmartInfoRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
machineMock = newMachineLearningRepositoryMock();
|
||||
databaseMock = newDatabaseRepositoryMock();
|
||||
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, smartMock, configMock);
|
||||
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock);
|
||||
|
||||
assetMock.getByIds.mockResolvedValue([asset]);
|
||||
});
|
||||
@@ -102,12 +102,12 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
await sut.handleEncodeClip({ id: asset.id });
|
||||
|
||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||
expect(searchMock.upsert).not.toHaveBeenCalled();
|
||||
expect(machineMock.encodeImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save the returned objects', async () => {
|
||||
smartMock.upsert.mockResolvedValue();
|
||||
searchMock.upsert.mockResolvedValue();
|
||||
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
||||
|
||||
await sut.handleEncodeClip({ id: asset.id });
|
||||
@@ -117,7 +117,7 @@ describe(SmartInfoService.name, () => {
|
||||
{ imagePath: 'path/to/resize.ext' },
|
||||
{ enabled: true, modelName: 'ViT-B-32__openai' },
|
||||
);
|
||||
expect(smartMock.upsert).toHaveBeenCalledWith(
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
assetId: 'asset-1',
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
IDatabaseRepository,
|
||||
IJobRepository,
|
||||
IMachineLearningRepository,
|
||||
ISmartInfoRepository,
|
||||
ISearchRepository,
|
||||
ISystemConfigRepository,
|
||||
WithoutProperty,
|
||||
} from '../repositories';
|
||||
@@ -24,7 +24,7 @@ export class SmartInfoService {
|
||||
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||
@Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
|
||||
@Inject(ISearchRepository) private repository: ISearchRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(configRepository);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ImmichLogger } from '@app/infra/logger';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { newCommunicationRepositoryMock, newSystemConfigRepositoryMock } from '@test';
|
||||
import { QueueName } from '../job';
|
||||
import { ICommunicationRepository, ISmartInfoRepository, ISystemConfigRepository, ServerEvent } from '../repositories';
|
||||
import { ICommunicationRepository, ISearchRepository, ISystemConfigRepository, ServerEvent } from '../repositories';
|
||||
import { defaults, SystemConfigValidator } from './system-config.core';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
|
||||
@@ -146,7 +146,7 @@ describe(SystemConfigService.name, () => {
|
||||
let sut: SystemConfigService;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
||||
let smartInfoMock: jest.Mocked<ISearchRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env.IMMICH_CONFIG_FILE;
|
||||
|
||||
@@ -6,7 +6,7 @@ import _ from 'lodash';
|
||||
import {
|
||||
ClientEvent,
|
||||
ICommunicationRepository,
|
||||
ISmartInfoRepository,
|
||||
ISearchRepository,
|
||||
ISystemConfigRepository,
|
||||
ServerEvent,
|
||||
} from '../repositories';
|
||||
@@ -32,7 +32,7 @@ export class SystemConfigService {
|
||||
constructor(
|
||||
@Inject(ISystemConfigRepository) private repository: ISystemConfigRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
||||
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
|
||||
) {
|
||||
this.core = SystemConfigCore.create(repository);
|
||||
this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate());
|
||||
|
||||
@@ -33,7 +33,9 @@ export class TrashService {
|
||||
|
||||
async restore(auth: AuthDto): Promise<void> {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }),
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, {
|
||||
trashedBefore: DateTime.now().toJSDate(),
|
||||
}),
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
@@ -44,7 +46,9 @@ export class TrashService {
|
||||
|
||||
async empty(auth: AuthDto): Promise<void> {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }),
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, {
|
||||
trashedBefore: DateTime.now().toJSDate(),
|
||||
}),
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
|
||||
Reference in New Issue
Block a user