520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely';
|
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
|
import { InjectKysely } from 'nestjs-kysely';
|
|
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
|
|
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
|
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
|
|
import { removeUndefinedKeys } from 'src/utils/database';
|
|
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
|
|
|
export interface PersonSearchOptions {
|
|
minimumFaceCount: number;
|
|
withHidden: boolean;
|
|
closestFaceAssetId?: string;
|
|
}
|
|
|
|
export interface PersonNameSearchOptions {
|
|
withHidden?: boolean;
|
|
}
|
|
|
|
export interface PersonNameResponse {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface AssetFaceId {
|
|
assetId: string;
|
|
personId: string;
|
|
}
|
|
|
|
export interface UpdateFacesData {
|
|
oldPersonId?: string;
|
|
faceIds?: string[];
|
|
newPersonId: 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 {
|
|
personId?: string | null;
|
|
assetId?: string;
|
|
sourceType?: SourceType;
|
|
}
|
|
|
|
export type UnassignFacesOptions = DeleteFacesOptions;
|
|
|
|
export type SelectFaceOptions = (keyof Selectable<AssetFaces>)[];
|
|
|
|
const withPerson = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
|
|
return jsonObjectFrom(
|
|
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'),
|
|
).as('person');
|
|
};
|
|
|
|
const withAsset = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
|
|
return jsonObjectFrom(
|
|
eb.selectFrom('assets').selectAll('assets').whereRef('assets.id', '=', 'asset_faces.assetId'),
|
|
).as('asset');
|
|
};
|
|
|
|
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
|
|
return jsonObjectFrom(
|
|
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_faces.id'),
|
|
).as('faceSearch');
|
|
};
|
|
|
|
@Injectable()
|
|
export class PersonRepository {
|
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
|
|
|
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
|
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
|
|
const result = await this.db
|
|
.updateTable('asset_faces')
|
|
.set({ personId: newPersonId })
|
|
.$if(!!oldPersonId, (qb) => qb.where('asset_faces.personId', '=', oldPersonId!))
|
|
.$if(!!faceIds, (qb) => qb.where('asset_faces.id', 'in', faceIds!))
|
|
.executeTakeFirst();
|
|
|
|
return Number(result.numChangedRows ?? 0);
|
|
}
|
|
|
|
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
|
await this.db
|
|
.updateTable('asset_faces')
|
|
.set({ personId: null })
|
|
.where('asset_faces.sourceType', '=', sourceType)
|
|
.execute();
|
|
}
|
|
|
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
|
async delete(ids: string[]): Promise<void> {
|
|
if (ids.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await this.db.deleteFrom('person').where('person.id', 'in', ids).execute();
|
|
}
|
|
|
|
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
|
|
await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute();
|
|
}
|
|
|
|
getAllFaces(options: GetAllFacesOptions = {}) {
|
|
return this.db
|
|
.selectFrom('asset_faces')
|
|
.selectAll('asset_faces')
|
|
.$if(options.personId === null, (qb) => qb.where('asset_faces.personId', 'is', null))
|
|
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
|
|
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
|
|
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.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();
|
|
}
|
|
|
|
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
|
|
const items = await this.db
|
|
.selectFrom('person')
|
|
.selectAll('person')
|
|
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
|
.innerJoin('assets', (join) =>
|
|
join
|
|
.onRef('asset_faces.assetId', '=', 'assets.id')
|
|
.on('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE))
|
|
.on('assets.deletedAt', 'is', null),
|
|
)
|
|
.where('person.ownerId', '=', userId)
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.orderBy('person.isHidden', 'asc')
|
|
.orderBy('person.isFavorite', 'desc')
|
|
.having((eb) =>
|
|
eb.or([
|
|
eb('person.name', '!=', ''),
|
|
eb((innerEb) => innerEb.fn.count('asset_faces.assetId'), '>=', options?.minimumFaceCount || 1),
|
|
]),
|
|
)
|
|
.groupBy('person.id')
|
|
.$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_faces.assetId'), 'desc')
|
|
.orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`)
|
|
.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_faces', 'asset_faces.personId', 'person.id')
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
|
|
.groupBy('person.id')
|
|
.execute();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaces(assetId: string) {
|
|
return this.db
|
|
.selectFrom('asset_faces')
|
|
.selectAll('asset_faces')
|
|
.select(withPerson)
|
|
.where('asset_faces.assetId', '=', assetId)
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.orderBy('asset_faces.boundingBoxX1', 'asc')
|
|
.execute();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaceById(id: string) {
|
|
// TODO return null instead of find or fail
|
|
return this.db
|
|
.selectFrom('asset_faces')
|
|
.selectAll('asset_faces')
|
|
.select(withPerson)
|
|
.where('asset_faces.id', '=', id)
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaceForFacialRecognitionJob(id: string) {
|
|
return this.db
|
|
.selectFrom('asset_faces')
|
|
.select(['asset_faces.id', 'asset_faces.personId', 'asset_faces.sourceType'])
|
|
.select((eb) =>
|
|
jsonObjectFrom(
|
|
eb
|
|
.selectFrom('assets')
|
|
.select(['assets.ownerId', 'assets.visibility', 'assets.fileCreatedAt'])
|
|
.whereRef('assets.id', '=', 'asset_faces.assetId'),
|
|
).as('asset'),
|
|
)
|
|
.select(withFaceSearch)
|
|
.where('asset_faces.id', '=', id)
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getDataForThumbnailGenerationJob(id: string) {
|
|
return this.db
|
|
.selectFrom('person')
|
|
.innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId')
|
|
.innerJoin('assets', 'asset_faces.assetId', 'assets.id')
|
|
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
|
.select([
|
|
'person.ownerId',
|
|
'asset_faces.boundingBoxX1 as x1',
|
|
'asset_faces.boundingBoxY1 as y1',
|
|
'asset_faces.boundingBoxX2 as x2',
|
|
'asset_faces.boundingBoxY2 as y2',
|
|
'asset_faces.imageWidth as oldWidth',
|
|
'asset_faces.imageHeight as oldHeight',
|
|
'assets.type',
|
|
'assets.originalPath',
|
|
'exif.orientation as exifOrientation',
|
|
])
|
|
.select((eb) =>
|
|
eb
|
|
.selectFrom('asset_files')
|
|
.select('asset_files.path')
|
|
.whereRef('asset_files.assetId', '=', 'assets.id')
|
|
.where('asset_files.type', '=', sql.lit(AssetFileType.PREVIEW))
|
|
.as('previewPath'),
|
|
)
|
|
.where('person.id', '=', id)
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
|
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
|
|
const result = await this.db
|
|
.updateTable('asset_faces')
|
|
.set({ personId: newPersonId })
|
|
.where('asset_faces.id', '=', assetFaceId)
|
|
.executeTakeFirst();
|
|
|
|
return Number(result.numChangedRows ?? 0);
|
|
}
|
|
|
|
getById(personId: string) {
|
|
return this.db //
|
|
.selectFrom('person')
|
|
.selectAll('person')
|
|
.where('person.id', '=', personId)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
|
|
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
|
|
return this.db
|
|
.selectFrom('person')
|
|
.selectAll('person')
|
|
.where((eb) =>
|
|
eb.and([
|
|
eb('person.ownerId', '=', userId),
|
|
eb.or([
|
|
eb(eb.fn('lower', ['person.name']), 'like', `${personName.toLowerCase()}%`),
|
|
eb(eb.fn('lower', ['person.name']), 'like', `% ${personName.toLowerCase()}%`),
|
|
]),
|
|
]),
|
|
)
|
|
.limit(1000)
|
|
.$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.id', '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] })
|
|
async getStatistics(personId: string): Promise<PersonStatistics> {
|
|
const result = await this.db
|
|
.selectFrom('asset_faces')
|
|
.leftJoin('assets', (join) =>
|
|
join
|
|
.onRef('assets.id', '=', 'asset_faces.assetId')
|
|
.on('asset_faces.personId', '=', personId)
|
|
.on('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE))
|
|
.on('assets.deletedAt', 'is', null),
|
|
)
|
|
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.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_faces')
|
|
.whereRef('asset_faces.personId', '=', 'person.id')
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.where((eb) =>
|
|
eb.exists((eb) =>
|
|
eb
|
|
.selectFrom('assets')
|
|
.whereRef('assets.id', '=', 'asset_faces.assetId')
|
|
.where('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE))
|
|
.where('assets.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<Person>) {
|
|
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
|
|
}
|
|
|
|
async createAll(people: Insertable<Person>[]): Promise<string[]> {
|
|
if (people.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const results = await this.db.insertInto('person').values(people).returningAll().execute();
|
|
return results.map(({ id }) => id);
|
|
}
|
|
|
|
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
|
|
async refreshFaces(
|
|
facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
|
|
faceIdsToRemove: string[],
|
|
embeddingsToAdd?: Insertable<FaceSearch>[],
|
|
): Promise<void> {
|
|
let query = this.db;
|
|
if (facesToAdd.length > 0) {
|
|
(query as any) = query.with('added', (db) => db.insertInto('asset_faces').values(facesToAdd));
|
|
}
|
|
|
|
if (faceIdsToRemove.length > 0) {
|
|
(query as any) = query.with('removed', (db) =>
|
|
db.deleteFrom('asset_faces').where('asset_faces.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(sql`(select 1)`.as('dummy')).execute();
|
|
}
|
|
|
|
async update(person: Updateable<Person> & { id: string }) {
|
|
return this.db
|
|
.updateTable('person')
|
|
.set(person)
|
|
.where('person.id', '=', person.id)
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
async updateAll(people: Insertable<Person>[]): Promise<void> {
|
|
if (people.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await this.db
|
|
.insertInto('person')
|
|
.values(people)
|
|
.onConflict((oc) =>
|
|
oc.column('id').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, personId: DummyValue.UUID }]] })
|
|
@ChunkedArray()
|
|
getFacesByIds(ids: AssetFaceId[]) {
|
|
if (ids.length === 0) {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
const assetIds: string[] = [];
|
|
const personIds: string[] = [];
|
|
for (const { assetId, personId } of ids) {
|
|
assetIds.push(assetId);
|
|
personIds.push(personId);
|
|
}
|
|
|
|
return this.db
|
|
.selectFrom('asset_faces')
|
|
.selectAll('asset_faces')
|
|
.select(withAsset)
|
|
.select(withPerson)
|
|
.where('asset_faces.assetId', 'in', assetIds)
|
|
.where('asset_faces.personId', 'in', personIds)
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.execute();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getRandomFace(personId: string) {
|
|
return this.db
|
|
.selectFrom('asset_faces')
|
|
.selectAll('asset_faces')
|
|
.where('asset_faces.personId', '=', personId)
|
|
.where('asset_faces.deletedAt', 'is', null)
|
|
.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<AssetFaces>): Promise<void> {
|
|
await this.db.insertInto('asset_faces').values(face).execute();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
async deleteAssetFace(id: string): Promise<void> {
|
|
await this.db.deleteFrom('asset_faces').where('asset_faces.id', '=', id).execute();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
async softDeleteAssetFaces(id: string): Promise<void> {
|
|
await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute();
|
|
}
|
|
|
|
async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
|
|
await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
|
|
await sql`REINDEX TABLE asset_faces`.execute(this.db);
|
|
await sql`REINDEX TABLE person`.execute(this.db);
|
|
if (reindexVectors) {
|
|
await sql`REINDEX TABLE face_search`.execute(this.db);
|
|
}
|
|
}
|
|
}
|