784 lines
27 KiB
TypeScript
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();
|
|
}
|
|
}
|