Files
Immich/server/src/repositories/person.repository.ts
T

784 lines
27 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { type ExpressionBuilder, type Insertable, type Kysely, sql, type Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database.js';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators.js';
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database.js';
import { paginationHelper, type PaginationOptions } from 'src/utils/pagination.js';
export interface PersonSearchOptions {
withHidden: boolean;
closestFaceAssetId?: string;
}
export interface PersonNameSearchOptions {
withHidden?: boolean;
}
export interface PersonNameResponse {
personGroupId: string;
name: string;
}
export interface AssetFaceId {
assetId: string;
personGroupId: string;
}
export interface UpdateFacesData {
oldPersonGroupId?: string;
faceIds?: string[];
ownerId?: string;
newPersonGroupId: string;
}
export interface PersonStatistics {
assets: number;
}
export interface DeleteFacesOptions {
sourceType: SourceType;
}
export interface GetAllPeopleOptions {
ownerId?: string;
thumbnailPath?: string;
faceAssetId?: string | null;
isHidden?: boolean;
}
export interface GetAllFacesOptions {
personGroupId?: string | null;
assetId?: string;
sourceType?: SourceType;
clusterGroupId?: string;
}
export type UnassignFacesOptions = DeleteFacesOptions & { clusterGroupId?: string };
export type GetFacesOptions = WithPersonOptions & { isVisible?: boolean };
/** a person is identified by its owner and the group it belongs to */
export type PersonId = { ownerId: string; personGroupId: string };
export type ReassignCluster = { userId: string; newClusterId: string };
export type WithPersonOptions = {
/** whose version of the person to select */
viewingUserId: string;
};
const withPerson = ({ viewingUserId }: WithPersonOptions) => {
return (eb: ExpressionBuilder<DB, 'asset_face'>) =>
jsonObjectFrom(
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
.where('person.ownerId', '=', viewingUserId),
).as('person');
};
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'),
).as('faceSearch');
};
@Injectable()
export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] })
async reassignFaces({ oldPersonGroupId, faceIds, ownerId, newPersonGroupId }: UpdateFacesData): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.from('asset')
.whereRef('asset_face.assetId', '=', 'asset.id')
.set({ personGroupId: newPersonGroupId })
.$if(!!oldPersonGroupId, (qb) => qb.where('asset_face.personGroupId', '=', oldPersonGroupId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.$if(!!ownerId, (qb) => qb.where('asset.ownerId', '=', ownerId!))
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0);
}
@GenerateSql({ params: [{ sourceType: SourceType.MachineLearning, clusterGroupId: DummyValue.UUID }] })
async unassignFaces({ sourceType, clusterGroupId }: UnassignFacesOptions): Promise<void> {
await this.db
.updateTable('asset_face')
.set({ personGroupId: null })
.from('asset')
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('asset_face.sourceType', '=', sourceType)
.$if(!!clusterGroupId, (qb) =>
qb.innerJoin('user', 'user.id', 'asset.ownerId').where('user.clusterGroupId', '=', clusterGroupId!),
)
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.UUID] })
@Chunked()
async delete(personGroupIds: string[], ownerId?: string) {
if (personGroupIds.length === 0) {
return [];
}
return this.db
.deleteFrom('person')
.$if(!!ownerId, (qb) => qb.where('ownerId', '=', ownerId!))
.where('person.personGroupId', 'in', personGroupIds)
.returning(['personGroupId', 'ownerId', 'thumbnailPath'])
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
async deleteGroups(ids: string[]): Promise<void> {
if (ids.length === 0) {
return;
}
await this.db.deleteFrom('person_group').where('person_group.id', 'in', ids).execute();
}
@GenerateSql()
async deleteEmptyGroups(): Promise<number> {
const result = await this.db
.deleteFrom('person_group')
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('person')
.whereRef('person.personGroupId', '=', 'person_group.id')
.select('person.personGroupId'),
),
),
)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
@GenerateSql()
async deleteOrphanedClusterGroups(): Promise<number> {
const result = await this.db
.deleteFrom('cluster_group')
.where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('user').whereRef('user.clusterGroupId', '=', 'cluster_group.id').select('user.id'))),
)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
await this.db.deleteFrom('asset_face').where('asset_face.sourceType', '=', sourceType).execute();
}
@GenerateSql({
params: [{ personGroupId: null, sourceType: SourceType.MachineLearning, clusterGroupId: DummyValue.UUID }],
stream: true,
})
getAllFaces(options: GetAllFacesOptions = {}) {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.$if(options.personGroupId === null, (qb) => qb.where('asset_face.personGroupId', 'is', null))
.$if(!!options.personGroupId, (qb) => qb.where('asset_face.personGroupId', '=', options.personGroupId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.$if(!!options.clusterGroupId, (qb) =>
qb
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.innerJoin('user', 'user.id', 'asset.ownerId')
.where('user.clusterGroupId', '=', options.clusterGroupId!),
)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.stream();
}
getAll(options: GetAllPeopleOptions = {}) {
return this.db
.selectFrom('person')
.selectAll('person')
.$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!))
.$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
.stream();
}
@GenerateSql()
getFileSamples() {
return this.db
.selectFrom('person')
.select(['ownerId', 'personGroupId', 'thumbnailPath'])
.where('thumbnailPath', '!=', sql.lit(''))
.limit(sql.lit(3))
.execute();
}
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
const items = await this.db
.selectFrom('person')
.selectAll('person')
.innerJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId')
.innerJoin('asset', (join) =>
join
.onRef('asset_face.assetId', '=', 'asset.id')
.onRef('asset.ownerId', '=', 'person.ownerId')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where('person.ownerId', '=', userId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
eb.or([
eb('person.name', '!=', ''),
eb(
(innerEb) => innerEb.fn.count('asset_face.assetId'),
'>=',
sql<number>`COALESCE(
(SELECT value -> 'people' ->> 'minimumFaces'
FROM user_metadata
WHERE "userId" = ${userId}
AND key = ${sql.lit(UserMetadataKey.Preferences)}),
'3'
)::int `,
),
]),
)
.groupBy(['person.ownerId', 'person.personGroupId'])
.$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) =>
eb(
(eb) =>
eb
.selectFrom('face_search')
.select('face_search.embedding')
.whereRef('face_search.faceId', '=', 'person.faceAssetId'),
'<=>',
(eb) =>
eb
.selectFrom('face_search')
.select('face_search.embedding')
.where('face_search.faceId', '=', options!.closestFaceAssetId!),
),
),
)
.$if(!options?.closestFaceAssetId, (qb) =>
qb
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
.orderBy((eb) => eb.fn.count('asset_face.assetId'), 'desc')
.orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast())
.orderBy('person.createdAt'),
)
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
.offset(pagination.skip ?? 0)
.limit(pagination.take + 1)
.execute();
return paginationHelper(items, pagination.take);
}
@GenerateSql()
getAllWithoutFaces() {
return this.db
.selectFrom('person')
.selectAll('person')
.leftJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId')
.where('asset_face.deletedAt', 'is', null)
.where((eb) => eb.or([eb('asset_face.isVisible', 'is', null), eb('asset_face.isVisible', '=', true)]))
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
.groupBy(['person.ownerId', 'person.personGroupId'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID, isVisible: true }] })
getFaces(assetId: string, options: GetFacesOptions) {
const { viewingUserId, isVisible } = options;
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson({ viewingUserId }))
.where('asset_face.assetId', '=', assetId)
.where('asset_face.deletedAt', 'is', null)
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
.orderBy('asset_face.boundingBoxX1', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID }] })
getFaceById(id: string, { viewingUserId }: WithPersonOptions) {
// TODO return null instead of find or fail
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson({ viewingUserId }))
.where('asset_face.id', '=', id)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceForFacialRecognitionJob(id: string) {
return this.db
.selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.personGroupId', 'asset_face.sourceType'])
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('asset')
.innerJoin('user', 'user.id', 'asset.ownerId')
.select(['asset.ownerId', 'asset.visibility', 'asset.fileCreatedAt', 'user.clusterGroupId'])
.whereRef('asset.id', '=', 'asset_face.assetId'),
).as('asset'),
)
.select(withFaceSearch)
.where('asset_face.id', '=', id)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirst();
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
getDataForThumbnailGenerationJob({ ownerId, personGroupId }: PersonId) {
return this.db
.selectFrom('person')
.innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId')
.innerJoin('asset', 'asset_face.assetId', 'asset.id')
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.select([
'person.ownerId',
'asset_face.boundingBoxX1 as x1',
'asset_face.boundingBoxY1 as y1',
'asset_face.boundingBoxX2 as x2',
'asset_face.boundingBoxY2 as y2',
'asset_face.imageWidth as oldWidth',
'asset_face.imageHeight as oldHeight',
'asset.type',
'asset.originalPath',
'asset_exif.orientation as exifOrientation',
])
.select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath'))
.where('person.ownerId', '=', ownerId)
.where('person.personGroupId', '=', personGroupId)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonGroupId: string): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ personGroupId: newPersonGroupId })
.where('asset_face.id', '=', assetFaceId)
.executeTakeFirst();
return Number(result.numChangedRows ?? 0);
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] })
getByGroupId({ ownerId, personGroupId }: PersonId) {
return this.db //
.selectFrom('person')
.selectAll('person')
.where('person.personGroupId', '=', personGroupId)
.where('person.ownerId', '=', ownerId)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
return this.db
.with('similarity_threshold', (db) =>
db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')),
)
.selectFrom(['similarity_threshold', 'person'])
.selectAll('person')
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
return this.db
.selectFrom('person')
.select(['person.personGroupId', 'person.name'])
.distinctOn((eb) => eb.fn('lower', ['person.name']))
.where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getStatistics(personGroupId: string, userId: string): Promise<PersonStatistics> {
const result = await this.db
.selectFrom('asset_face')
.leftJoin('asset', (join) =>
join
.onRef('asset.id', '=', 'asset_face.assetId')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null)
.on((eb) => eb.or([eb('asset.ownerId', '=', asUuid(userId)), inSharedAlbum(eb, userId)])),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.where('asset_face.personGroupId', '=', personGroupId)
.executeTakeFirst();
return {
assets: result ? Number(result.count) : 0,
};
}
@GenerateSql({ params: [DummyValue.UUID] })
getNumberOfPeople(userId: string) {
const zero = sql.lit(0);
return this.db
.selectFrom('person')
.where((eb) =>
eb.exists((eb) =>
eb
.selectFrom('asset_face')
.whereRef('asset_face.personGroupId', '=', 'person.personGroupId')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.where((eb) =>
eb.exists((eb) =>
eb
.selectFrom('asset')
.whereRef('asset.id', '=', 'asset_face.assetId')
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.where('asset.deletedAt', 'is', null),
),
),
),
)
.where('person.ownerId', '=', userId)
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden'))
.executeTakeFirstOrThrow();
}
create(person: Insertable<PersonTable>) {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
}
async createAll(people: Insertable<PersonTable>[]) {
if (people.length === 0) {
return [];
}
return this.db.insertInto('person').values(people).returningAll().execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
createGroup(ownerId: string) {
return this.db
.insertInto('person_group')
.columns(['clusterGroupId'])
.expression((eb) => eb.selectFrom('user').select('user.clusterGroupId').where('user.id', '=', ownerId))
.returningAll()
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, newClusterId: DummyValue.UUID }] })
async reassignCluster({ userId, newClusterId }: ReassignCluster): Promise<void> {
await this.db.transaction().execute(async (trx) => {
// a group nobody else has people in moves across as it is
await trx
.updateTable('person_group')
.set({ clusterGroupId: newClusterId })
.where('person_group.id', 'in', (eb) =>
eb.selectFrom('person').select('person.personGroupId').where('person.ownerId', '=', userId),
)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('person')
.select('person.personGroupId')
.whereRef('person.personGroupId', '=', 'person_group.id')
.where('person.ownerId', '!=', userId),
),
),
)
.execute();
// the rest is shared with someone else, so this user gets a group of their own for each
const mapping = await trx
.with('shared', (db) =>
db
.selectFrom('person')
.select('person.personGroupId as oldId')
.distinct()
.where('person.ownerId', '=', userId)
.where(({ exists, selectFrom }) =>
exists(
selectFrom('person as other')
.select('other.personGroupId')
.whereRef('other.personGroupId', '=', 'person.personGroupId')
.where('other.ownerId', '!=', userId),
),
),
)
.with(
(cte) => cte('mapping').materialized(),
(db) => db.selectFrom('shared').select(['shared.oldId', sql<string>`uuid_generate_v4()`.as('newId')]),
)
.with('created', (db) =>
db
.insertInto('person_group')
.columns(['id', 'clusterGroupId'])
.expression((eb) =>
eb.selectFrom('mapping').select(['mapping.newId', sql.val(newClusterId).as('clusterGroupId')]),
),
)
.selectFrom('mapping')
.select(['mapping.oldId', 'mapping.newId'])
.execute();
if (mapping.length === 0) {
return;
}
const oldIds = mapping.map(({ oldId }) => oldId);
const newIds = mapping.map(({ newId }) => newId);
const remapped = sql<{
oldId: string;
newId: string;
}>`(select unnest(${`{${oldIds}}`}::uuid[]) as "oldId", unnest(${`{${newIds}}`}::uuid[]) as "newId")`.as(
'mapping',
);
await trx
.updateTable('person')
.from(remapped)
.set((eb) => ({ personGroupId: eb.ref('mapping.newId') }))
.whereRef('person.personGroupId', '=', 'mapping.oldId')
.where('person.ownerId', '=', userId)
.execute();
await trx
.updateTable('asset_face')
.from(remapped)
.set((eb) => ({ personGroupId: eb.ref('mapping.newId') }))
.whereRef('asset_face.personGroupId', '=', 'mapping.oldId')
.where(({ exists, selectFrom }) =>
exists(
selectFrom('asset')
.select('asset.id')
.whereRef('asset.id', '=', 'asset_face.assetId')
.where('asset.ownerId', '=', userId),
),
)
.execute();
});
}
@GenerateSql({ params: [DummyValue.UUID, 2] })
async createGroups(personGroups: Insertable<PersonGroupTable>[]) {
if (personGroups.length === 0) {
return [];
}
return this.db.insertInto('person_group').values(personGroups).returningAll().execute();
}
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
async refreshFaces(
facesToAdd: (Insertable<AssetFaceTable> & { assetId: string })[],
faceIdsToRemove: string[],
embeddingsToAdd?: Insertable<FaceSearchTable>[],
): Promise<void> {
let query = this.db;
if (facesToAdd.length > 0) {
(query as any) = query.with('added', (db) => db.insertInto('asset_face').values(facesToAdd));
}
if (faceIdsToRemove.length > 0) {
(query as any) = query.with('removed', (db) =>
db.deleteFrom('asset_face').where('asset_face.id', '=', (eb) => eb.fn.any(eb.val(faceIdsToRemove))),
);
}
if (embeddingsToAdd?.length) {
(query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
}
await query.selectFrom(dummy).execute();
}
async update(person: Updateable<PersonTable> & PersonId) {
return this.db
.updateTable('person')
.set(person)
.where('person.ownerId', '=', person.ownerId)
.where('person.personGroupId', '=', person.personGroupId)
.returningAll()
.executeTakeFirstOrThrow();
}
async updateAll(people: Insertable<PersonTable>[]): Promise<void> {
if (people.length === 0) {
return;
}
await this.db
.insertInto('person')
.values(people)
.onConflict((oc) =>
oc.columns(['ownerId', 'personGroupId']).doUpdateSet((eb) =>
removeUndefinedKeys(
{
name: eb.ref('excluded.name'),
birthDate: eb.ref('excluded.birthDate'),
thumbnailPath: eb.ref('excluded.thumbnailPath'),
faceAssetId: eb.ref('excluded.faceAssetId'),
isHidden: eb.ref('excluded.isHidden'),
isFavorite: eb.ref('excluded.isFavorite'),
color: eb.ref('excluded.color'),
},
people[0],
),
),
)
.execute();
}
@GenerateSql({
params: [[{ assetId: DummyValue.UUID, personGroupId: DummyValue.UUID }], { viewingUserId: DummyValue.UUID }],
})
@ChunkedArray()
getFacesByIds(ids: AssetFaceId[], { viewingUserId }: WithPersonOptions) {
if (ids.length === 0) {
return Promise.resolve([]);
}
const assetIds: string[] = [];
const personGroupIds: string[] = [];
for (const { assetId, personGroupId } of ids) {
assetIds.push(assetId);
personGroupIds.push(personGroupId);
}
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson({ viewingUserId }))
.where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personGroupId', 'in', personGroupIds)
.where('asset_face.deletedAt', 'is', null)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getRandomFace(personGroupId: string) {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.where('asset_face.personGroupId', '=', personGroupId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.executeTakeFirst();
}
@GenerateSql()
async getLatestFaceDate(): Promise<string | undefined> {
const result = (await this.db
.selectFrom('asset_job_status')
.select((eb) => sql`${eb.fn.max('asset_job_status.facesRecognizedAt')}::text`.as('latestDate'))
.executeTakeFirst()) as { latestDate: string } | undefined;
return result?.latestDate;
}
async createAssetFace(face: Insertable<AssetFaceTable>): Promise<void> {
await this.db.insertInto('asset_face').values(face).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteAssetFace(id: string): Promise<void> {
await this.db.deleteFrom('asset_face').where('asset_face.id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async softDeleteAssetFaces(id: string): Promise<void> {
await this.db.updateTable('asset_face').set({ deletedAt: new Date() }).where('asset_face.id', '=', id).execute();
}
@GenerateSql({ params: [[], []] })
async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise<void> {
if (visible.length === 0 && hidden.length === 0) {
return;
}
await this.db.transaction().execute(async (trx) => {
if (visible.length > 0) {
await trx
.updateTable('asset_face')
.set({ isVisible: true })
.where(
'asset_face.id',
'in',
visible.map(({ id }) => id),
)
.execute();
}
if (hidden.length > 0) {
await trx
.updateTable('asset_face')
.set({ isVisible: false })
.where(
'asset_face.id',
'in',
hidden.map(({ id }) => id),
)
.execute();
}
});
}
@GenerateSql({ params: [{ personGroupId: DummyValue.UUID, assetId: DummyValue.UUID }] })
getForFeatureFaceUpdate({ personGroupId, assetId }: { personGroupId: string; assetId: string }) {
return this.db
.selectFrom('asset_face')
.select('asset_face.id')
.where('asset_face.assetId', '=', assetId)
.where('asset_face.personGroupId', '=', personGroupId)
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
.executeTakeFirst();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
getForMergePerson(personGroupIds: string[]) {
return this.db
.selectFrom('person')
.selectAll('person')
.where('person.personGroupId', 'in', personGroupIds)
.orderBy('person.ownerId')
.execute();
}
}