Files
immich/server/src/infra/repositories/asset.repository.ts
T
Mert 68f52818ae feat(server): separate face clustering job (#5598)
* separate facial clustering job

* update api

* fixed some tests

* invert clustering

* hdbscan

* update api

* remove commented code

* wip dbscan

* cleanup

removed cluster endpoint

remove commented code

* fixes

updated tests

minor fixes and formatting

fixed queuing

refinements

* scale search range based on library size

* defer non-core faces

* optimizations

removed unused query option

* assign faces individually for correctness

fixed unit tests

remove unused method

* don't select face embedding

update sql

linting

fixed ml typing

* updated job mock

* paginate people query

* select face embeddings because typeorm

* fix setting face detection concurrency

* update sql

formatting

linting

* simplify logic

remove unused imports

* more specific delete signature

* more accurate typing for face stubs

* add migration

formatting

* chore: better typing

* don't select embedding by default

remove unused import

* updated sql

* use normal try/catch

* stricter concurrency typing and enforcement

* update api

* update job concurrency panel to show disabled queues

formatting

* check jobId in queueAll

fix tests

* remove outdated comment

* better facial recognition icon

* wording

wording

formatting

* fixed tests

* fix

* formatting & sql

* try to fix sql check

* more detailed description

* update sql

* formatting

* wording

* update `minFaces` description

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-01-18 00:08:48 -05:00

926 lines
25 KiB
TypeScript

import {
AssetBuilderOptions,
AssetCreate,
AssetExploreFieldOptions,
AssetSearchOptions,
AssetStats,
AssetStatsOptions,
IAssetRepository,
LivePhotoSearchOptions,
MapMarker,
MapMarkerSearchOptions,
MetadataSearchOptions,
MonthDay,
Paginated,
PaginationOptions,
SearchExploreItem,
TimeBucketItem,
TimeBucketOptions,
TimeBucketSize,
WithProperty,
WithoutProperty,
} from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
import { DateTime } from 'luxon';
import path from 'path';
import {
And,
Brackets,
FindOptionsRelations,
FindOptionsSelect,
FindOptionsWhere,
In,
IsNull,
LessThan,
Not,
Repository,
} from 'typeorm';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util';
import { Chunked, ChunkedArray, OptionalBetween, paginate } from '../infra.utils';
const DEFAULT_SEARCH_SIZE = 250;
const truncateMap: Record<TimeBucketSize, string> = {
[TimeBucketSize.DAY]: 'day',
[TimeBucketSize.MONTH]: 'month',
};
const dateTrunc = (options: TimeBucketOptions) =>
`(date_trunc('${
truncateMap[options.size]
}', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`;
@Injectable()
export class AssetRepository implements IAssetRepository {
constructor(
@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
@InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository<SmartInfoEntity>,
) {}
async upsertExif(exif: Partial<ExifEntity>): Promise<void> {
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
}
async upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void> {
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
}
search(options: AssetSearchOptions): Promise<AssetEntity[]> {
const {
id,
libraryId,
deviceAssetId,
type,
checksum,
ownerId,
isVisible,
isFavorite,
isExternal,
isReadOnly,
isOffline,
isArchived,
isMotion,
isEncoded,
createdBefore,
createdAfter,
updatedBefore,
updatedAfter,
trashedBefore,
trashedAfter,
takenBefore,
takenAfter,
originalFileName,
originalPath,
resizePath,
webpPath,
encodedVideoPath,
city,
state,
country,
make,
model,
lensModel,
withDeleted: _withDeleted,
withExif: _withExif,
withStacked,
withPeople,
withSmartInfo,
order,
} = options;
const withDeleted = _withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined);
const page = Math.max(options.page || 1, 1);
const size = Math.min(options.size || DEFAULT_SEARCH_SIZE, DEFAULT_SEARCH_SIZE);
const exifWhere = _.omitBy(
{
city,
state,
country,
make,
model,
lensModel,
},
_.isUndefined,
);
const withExif = Object.keys(exifWhere).length > 0 || _withExif;
const where = _.omitBy(
{
ownerId,
id,
libraryId,
deviceAssetId,
type,
checksum,
isVisible,
isFavorite,
isExternal,
isReadOnly,
isOffline,
isArchived,
livePhotoVideoId: isMotion && Not(IsNull()),
originalFileName,
originalPath,
resizePath,
webpPath,
encodedVideoPath: encodedVideoPath ?? (isEncoded && Not(IsNull())),
createdAt: OptionalBetween(createdAfter, createdBefore),
updatedAt: OptionalBetween(updatedAfter, updatedBefore),
deletedAt: OptionalBetween(trashedAfter, trashedBefore),
fileCreatedAt: OptionalBetween(takenAfter, takenBefore),
exifInfo: Object.keys(exifWhere).length > 0 ? exifWhere : undefined,
},
_.isUndefined,
);
const builder = this.repository.createQueryBuilder('asset');
if (withExif) {
if (_withExif) {
builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo');
} else {
builder.leftJoin('asset.exifInfo', 'exifInfo');
}
}
if (withPeople) {
builder.leftJoinAndSelect('asset.faces', 'faces');
builder.leftJoinAndSelect('faces.person', 'person');
}
if (withStacked) {
builder.leftJoinAndSelect('asset.stack', 'stack');
}
if (withSmartInfo) {
builder.leftJoinAndSelect('asset.smartInfo', 'smartInfo');
}
if (withDeleted) {
builder.withDeleted();
}
builder
.where(where)
.skip(size * (page - 1))
.take(size)
.orderBy('asset.fileCreatedAt', order ?? 'DESC');
return builder.getMany();
}
create(asset: AssetCreate): Promise<AssetEntity> {
return this.repository.save(asset);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> {
// For reference of a correct approach although slower
// let builder = this.repository
// .createQueryBuilder('asset')
// .leftJoin('asset.exifInfo', 'exifInfo')
// .where('asset.ownerId = :ownerId', { ownerId })
// .andWhere(
// `coalesce(date_trunc('day', asset."fileCreatedAt", "exifInfo"."timeZone") at TIME ZONE "exifInfo"."timeZone", date_trunc('day', asset."fileCreatedAt")) IN (:date)`,
// { date },
// )
// .andWhere('asset.isVisible = true')
// .andWhere('asset.isArchived = false')
// .orderBy('asset.fileCreatedAt', 'DESC');
// return builder.getMany();
return this.repository.find({
where: {
ownerId,
isVisible: true,
isArchived: false,
resizePath: Not(IsNull()),
fileCreatedAt: OptionalBetween(date, DateTime.fromJSDate(date).plus({ day: 1 }).toJSDate()),
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
}
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
getByDayOfYear(ownerId: string, { day, month }: MonthDay): Promise<AssetEntity[]> {
return this.repository
.createQueryBuilder('entity')
.where(
`entity.ownerId = :ownerId
AND entity.isVisible = true
AND entity.isArchived = false
AND entity.resizePath IS NOT NULL
AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day
AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`,
{
ownerId,
day,
month,
},
)
.leftJoinAndSelect('entity.exifInfo', 'exifInfo')
.orderBy('entity.localDateTime', 'DESC')
.getMany();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIds(
ids: string[],
relations?: FindOptionsRelations<AssetEntity>,
select?: FindOptionsSelect<AssetEntity>,
): Promise<AssetEntity[]> {
if (!relations) {
relations = {
exifInfo: true,
smartInfo: true,
tags: true,
faces: {
person: true,
},
stack: true,
};
}
return this.repository.find({
where: { id: In(ids) },
relations,
select,
withDeleted: true,
});
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteAll(ownerId: string): Promise<void> {
await this.repository.delete({ ownerId });
}
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {
albums: {
id: albumId,
},
},
relations: {
albums: true,
exifInfo: true,
},
});
}
getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {
ownerId: userId,
isVisible: options.isVisible,
deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined,
},
relations: {
exifInfo: true,
},
withDeleted: !!options.trashedBefore,
});
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByLibraryId(libraryIds: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { library: { id: In(libraryIds) } },
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { library: { id: libraryId }, originalPath: originalPath },
});
}
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {
isVisible: options.isVisible,
type: options.type,
deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined,
},
relations: {
exifInfo: options.withExif !== false,
smartInfo: options.withSmartInfo !== false,
tags: options.withSmartInfo !== false,
faces: options.withFaces !== false,
smartSearch: options.withSmartInfo === true,
},
withDeleted: options.withDeleted ?? !!options.trashedBefore,
order: {
// Ensures correct order when paginating
createdAt: options.order ?? 'ASC',
},
});
}
/**
* Get assets by device's Id on the database
* @param ownerId
* @param deviceId
*
* @returns Promise<string[]> - Array of assetIds belong to the device
*/
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
const items = await this.repository.find({
select: { deviceAssetId: true },
where: {
ownerId,
deviceId,
isVisible: true,
},
withDeleted: true,
});
return items.map((asset) => asset.deviceAssetId);
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string, relations: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { id },
relations,
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
});
}
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
@Chunked()
async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
await this.repository.update({ id: In(ids) }, options);
}
@Chunked()
async softDeleteAll(ids: string[]): Promise<void> {
await this.repository.softDelete({ id: In(ids), isExternal: false });
}
@Chunked()
async restoreAll(ids: string[]): Promise<void> {
await this.repository.restore({ id: In(ids) });
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
const { id } = await this.repository.save(asset);
return this.repository.findOneOrFail({
where: { id },
relations: {
exifInfo: true,
owner: true,
smartInfo: true,
tags: true,
faces: {
person: true,
},
},
withDeleted: true,
});
}
async remove(asset: AssetEntity): Promise<void> {
await this.repository.remove(asset);
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.BUFFER] })
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
return this.repository.findOne({ where: { ownerId: userId, checksum } });
}
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null> {
const { ownerId, otherAssetId, livePhotoCID, type } = options;
return this.repository.findOne({
where: {
id: Not(otherAssetId),
ownerId,
type,
exifInfo: {
livePhotoCID,
},
},
relations: {
exifInfo: true,
},
});
}
@GenerateSql(
...Object.values(WithProperty)
.filter((property) => property !== WithProperty.IS_OFFLINE)
.map((property) => ({
name: property,
params: [DummyValue.PAGINATION, property],
})),
)
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity> {
let relations: FindOptionsRelations<AssetEntity> = {};
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithoutProperty.THUMBNAIL:
where = [
{ resizePath: IsNull(), isVisible: true },
{ resizePath: '', isVisible: true },
{ webpPath: IsNull(), isVisible: true },
{ webpPath: '', isVisible: true },
{ thumbhash: IsNull(), isVisible: true },
];
break;
case WithoutProperty.ENCODED_VIDEO:
where = [
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() },
{ type: AssetType.VIDEO, encodedVideoPath: '' },
];
break;
case WithoutProperty.EXIF:
relations = {
exifInfo: true,
jobStatus: true,
};
where = {
isVisible: true,
jobStatus: {
metadataExtractedAt: IsNull(),
},
};
break;
case WithoutProperty.CLIP_ENCODING:
relations = {
smartSearch: true,
};
where = {
isVisible: true,
resizePath: Not(IsNull()),
smartSearch: {
embedding: IsNull(),
},
};
break;
case WithoutProperty.OBJECT_TAGS:
relations = {
smartInfo: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
smartInfo: {
tags: IsNull(),
},
};
break;
case WithoutProperty.FACES:
relations = {
faces: true,
jobStatus: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
faces: {
assetId: IsNull(),
personId: IsNull(),
},
jobStatus: {
facesRecognizedAt: IsNull(),
},
};
break;
case WithoutProperty.PERSON:
relations = {
faces: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
faces: {
assetId: Not(IsNull()),
personId: IsNull(),
},
};
break;
case WithoutProperty.SIDECAR:
where = [
{ sidecarPath: IsNull(), isVisible: true },
{ sidecarPath: '', isVisible: true },
];
break;
default:
throw new Error(`Invalid getWithout property: ${property}`);
}
return paginate(this.repository, pagination, {
relations,
where,
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity> {
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithProperty.SIDECAR:
where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
break;
case WithProperty.IS_OFFLINE:
if (!libraryId) {
throw new Error('Library id is required when finding offline assets');
}
where = [{ isOffline: true, libraryId: libraryId }];
break;
default:
throw new Error(`Invalid getWith property: ${property}`);
}
return paginate(this.repository, pagination, {
where,
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
order: { fileCreatedAt: 'DESC' },
});
}
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
order: { updatedAt: 'DESC' },
});
}
async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
const assets = await this.repository.find({
select: {
id: true,
exifInfo: {
latitude: true,
longitude: true,
},
},
where: {
ownerId,
isVisible: true,
isArchived,
exifInfo: {
latitude: Not(IsNull()),
longitude: Not(IsNull()),
},
isFavorite,
fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore),
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
return assets.map((asset) => ({
id: asset.id,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lat: asset.exifInfo!.latitude!,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lon: asset.exifInfo!.longitude!,
}));
}
async getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats> {
let builder = await this.repository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.where('"ownerId" = :ownerId', { ownerId })
.andWhere('asset.isVisible = true')
.groupBy('asset.type');
const { isArchived, isFavorite, isTrashed } = options;
if (isArchived !== undefined) {
builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived });
}
if (isFavorite !== undefined) {
builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite });
}
if (isTrashed !== undefined) {
builder = builder.withDeleted().andWhere(`asset.deletedAt is not null`);
}
const items = await builder.getRawMany();
const result: AssetStats = {
[AssetType.AUDIO]: 0,
[AssetType.IMAGE]: 0,
[AssetType.VIDEO]: 0,
[AssetType.OTHER]: 0,
};
for (const item of items) {
result[item.type as AssetType] = Number(item.count) || 0;
}
return result;
}
getRandom(ownerId: string, count: number): Promise<AssetEntity[]> {
// can't use queryBuilder because of custom OFFSET clause
return this.repository.query(
`SELECT *
FROM assets
WHERE "ownerId" = $1
OFFSET FLOOR(RANDOM() * (SELECT GREATEST(COUNT(*) - $2, 0) FROM ASSETS WHERE "ownerId" = $1)) LIMIT $2`,
[ownerId, count],
);
}
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
const truncated = dateTrunc(options);
return this.getBuilder(options)
.select(`COUNT(asset.id)::int`, 'count')
.addSelect(truncated, 'timeBucket')
.groupBy(truncated)
.orderBy(truncated, 'DESC')
.getRawMany();
}
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH }] })
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> {
const truncated = dateTrunc(options);
return (
this.getBuilder(options)
.andWhere(`${truncated} = :timeBucket`, { timeBucket: timeBucket.replace(/^[+-]/, '') })
// First sort by the day in localtime (put it in the right bucket)
.orderBy(truncated, 'DESC')
// and then sort by the actual time
.addOrderBy('asset.fileCreatedAt', 'DESC')
.getMany()
);
}
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByCity(
ownerId: string,
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
): Promise<SearchExploreItem<string>> {
const cte = this.exifRepository
.createQueryBuilder('e')
.select('city')
.groupBy('city')
.having('count(city) >= :minAssetsPerField', { minAssetsPerField });
const items = await this.getBuilder({
userIds: [ownerId],
exifInfo: false,
assetType: AssetType.IMAGE,
isArchived: false,
})
.select('c.city', 'value')
.addSelect('asset.id', 'data')
.distinctOn(['c.city'])
.innerJoin('exif', 'e', 'asset.id = e."assetId"')
.addCommonTableExpression(cte, 'cities')
.innerJoin('cities', 'c', 'c.city = e.city')
.limit(maxFields)
.getRawMany();
return { fieldName: 'exifInfo.city', items };
}
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByTag(
ownerId: string,
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
): Promise<SearchExploreItem<string>> {
const cte = this.smartInfoRepository
.createQueryBuilder('si')
.select('unnest(tags)', 'tag')
.groupBy('tag')
.having('count(*) >= :minAssetsPerField', { minAssetsPerField });
const items = await this.getBuilder({
userIds: [ownerId],
exifInfo: false,
assetType: AssetType.IMAGE,
isArchived: false,
})
.select('unnest(si.tags)', 'value')
.addSelect('asset.id', 'data')
.distinctOn(['unnest(si.tags)'])
.innerJoin('smart_info', 'si', 'asset.id = si."assetId"')
.addCommonTableExpression(cte, 'random_tags')
.innerJoin('random_tags', 't', 'si.tags @> ARRAY[t.tag]')
.limit(maxFields)
.getRawMany();
return { fieldName: 'smartInfo.tags', items };
}
private getBuilder(options: AssetBuilderOptions) {
const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options;
let builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true');
if (assetType !== undefined) {
builder = builder.andWhere('asset.type = :assetType', { assetType });
}
if (exifInfo !== false) {
builder = builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo').leftJoinAndSelect('asset.stack', 'stack');
}
if (albumId) {
builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId });
}
if (userIds) {
builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds });
}
if (isArchived !== undefined) {
builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived });
}
if (isFavorite !== undefined) {
builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite });
}
if (isTrashed !== undefined) {
builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
}
if (personId !== undefined) {
builder = builder
.innerJoin('asset.faces', 'faces')
.innerJoin('faces.person', 'person')
.andWhere('person.id = :personId', { personId });
}
if (withStacked) {
builder = builder.andWhere('asset.stackParentId IS NULL');
}
return builder;
}
@GenerateSql({ params: [DummyValue.STRING, [DummyValue.UUID], { numResults: 250 }] })
async searchMetadata(
query: string,
userIds: string[],
{ numResults }: MetadataSearchOptions,
): Promise<AssetEntity[]> {
const rows = await this.getBuilder({
userIds: userIds,
exifInfo: false,
isArchived: false,
})
.select('asset.*')
.addSelect('e.*')
.addSelect('COALESCE(si.tags, array[]::text[])', 'tags')
.addSelect('COALESCE(si.objects, array[]::text[])', 'objects')
.innerJoin('exif', 'e', 'asset."id" = e."assetId"')
.leftJoin('smart_info', 'si', 'si."assetId" = asset."id"')
.andWhere(
new Brackets((qb) => {
qb.where(
`(e."exifTextSearchableColumn" || COALESCE(si."smartInfoTextSearchableColumn", to_tsvector('english', '')))
@@ PLAINTO_TSQUERY('english', :query)`,
{ query },
).orWhere('asset."originalFileName" = :path', { path: path.parse(query).name });
}),
)
.addOrderBy('asset.fileCreatedAt', 'DESC')
.limit(numResults)
.getRawMany();
return rows.map(
({
tags,
objects,
country,
state,
city,
description,
model,
make,
dateTimeOriginal,
exifImageHeight,
exifImageWidth,
exposureTime,
fNumber,
fileSizeInByte,
focalLength,
iso,
latitude,
lensModel,
longitude,
modifyDate,
projectionType,
timeZone,
...assetInfo
}) =>
({
exifInfo: {
city,
country,
dateTimeOriginal,
description,
exifImageHeight,
exifImageWidth,
exposureTime,
fNumber,
fileSizeInByte,
focalLength,
iso,
latitude,
lensModel,
longitude,
make,
model,
modifyDate,
projectionType,
state,
timeZone,
},
smartInfo: {
tags,
objects,
},
...assetInfo,
}) as AssetEntity,
);
}
}