This commit is contained in:
Daniel Dietzler 2026-08-11 11:40:38 +02:00 committed by GitHub
commit 25bf5d3c9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 1987 additions and 659 deletions

View file

@ -125,7 +125,7 @@ describe('/asset', () => {
}),
]);
const person1 = await utils.createPerson(user1.accessToken, {
const person1 = await utils.createPerson(user1, {
name: 'Test Person',
});
await utils.createFace({

View file

@ -37,40 +37,40 @@ describe('/people', () => {
nameBillPersonFavourite,
nameFreddyPersonFavourite,
] = await Promise.all([
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'visible_person',
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'hidden_person',
isHidden: true,
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'multiple_assets_person',
}),
// --- Setup for the specific sorting test ---
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'Charlie',
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'Bob',
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'Alice',
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: '',
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: '',
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: '',
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'Bill',
isFavorite: true,
}),
utils.createPerson(admin.accessToken, {
utils.createPerson(admin, {
name: 'Freddy',
isFavorite: true,
}),
@ -315,7 +315,7 @@ describe('/people', () => {
});
it('should mark a person as favorite', async () => {
const person = await utils.createPerson(admin.accessToken, {
const person = await utils.createPerson(admin, {
name: 'visible_person',
});

View file

@ -7,6 +7,7 @@ import {
CreateAlbumDto,
CreateLibraryDto,
JobCreateDto,
LoginResponseDto,
MaintenanceAction,
ManualJobName,
MetadataSearchDto,
@ -427,9 +428,9 @@ export const utils = {
deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
createPerson: async (accessToken: string, dto?: PersonCreateDto) => {
const person = await createPerson({ personCreateDto: dto || {} }, { headers: asBearerAuth(accessToken) });
await utils.setPersonThumbnail(person.id);
createPerson: async (login: LoginResponseDto, dto?: PersonCreateDto) => {
const person = await createPerson({ personCreateDto: dto || {} }, { headers: asBearerAuth(login.accessToken) });
await utils.setPersonThumbnail(person.id, login.userId);
return person;
},
@ -442,12 +443,15 @@ export const utils = {
await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
},
setPersonThumbnail: async (personId: string) => {
setPersonThumbnail: async (personId: string, ownerId: string) => {
if (!client) {
return;
}
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
await client.query(
`UPDATE "person_user" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "personId" = $1 and "ownerId" = $2`,
[personId, ownerId],
);
},
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>

View file

@ -25061,7 +25061,10 @@
"StackV1",
"StackDeleteV1",
"PersonV1",
"PersonV2",
"PersonDeleteV1",
"PersonUserV1",
"PersonUserDeleteV1",
"AssetFaceV1",
"AssetFaceV2",
"AssetFaceDeleteV1",
@ -25286,6 +25289,82 @@
],
"type": "object"
},
"SyncPersonUserDeleteV1": {
"properties": {
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"personId": {
"description": "Person ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
"required": [
"ownerId",
"personId"
],
"type": "object"
},
"SyncPersonUserV1": {
"properties": {
"createdAt": {
"description": "Created at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"isFavorite": {
"description": "Is favorite",
"type": "boolean"
},
"isHidden": {
"description": "Is hidden",
"type": "boolean"
},
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"personId": {
"description": "Person ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"thumbnailFaceAssetId": {
"description": "Face asset ID",
"format": "uuid",
"nullable": true,
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"updatedAt": {
"description": "Updated at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
}
},
"required": [
"createdAt",
"isFavorite",
"isHidden",
"ownerId",
"personId",
"thumbnailFaceAssetId",
"updatedAt"
],
"type": "object"
},
"SyncPersonV1": {
"properties": {
"birthDate": {
@ -25359,6 +25438,63 @@
],
"type": "object"
},
"SyncPersonV2": {
"properties": {
"birthDate": {
"description": "Birth date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"color": {
"description": "Color",
"nullable": true,
"type": "string"
},
"createdAt": {
"description": "Created at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"id": {
"description": "Person ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
"description": "Person name",
"type": "string"
},
"trustedGroupId": {
"description": "Trusted group ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"updatedAt": {
"description": "Updated at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
}
},
"required": [
"birthDate",
"color",
"createdAt",
"id",
"name",
"trustedGroupId",
"updatedAt"
],
"type": "object"
},
"SyncRequestType": {
"description": "Sync request type",
"enum": [
@ -25386,6 +25522,8 @@
"StacksV1",
"UsersV1",
"PeopleV1",
"PeopleV2",
"PersonUsersV1",
"AssetFacesV1",
"AssetFacesV2",
"UserMetadataV1"

View file

@ -3273,6 +3273,28 @@ export type SyncPersonDeleteV1 = {
/** Person ID */
personId: string;
};
export type SyncPersonUserDeleteV1 = {
/** Owner ID */
ownerId: string;
/** Person ID */
personId: string;
};
export type SyncPersonUserV1 = {
/** Created at */
createdAt: string;
/** Is favorite */
isFavorite: boolean;
/** Is hidden */
isHidden: boolean;
/** Owner ID */
ownerId: string;
/** Person ID */
personId: string;
/** Face asset ID */
thumbnailFaceAssetId: string | null;
/** Updated at */
updatedAt: string;
};
export type SyncPersonV1 = {
/** Birth date */
birthDate: string | null;
@ -3295,6 +3317,22 @@ export type SyncPersonV1 = {
/** Updated at */
updatedAt: string;
};
export type SyncPersonV2 = {
/** Birth date */
birthDate: string | null;
/** Color */
color: string | null;
/** Created at */
createdAt: string;
/** Person ID */
id: string;
/** Person name */
name: string;
/** Trusted group ID */
trustedGroupId: string;
/** Updated at */
updatedAt: string;
};
export type SyncResetV1 = {};
export type SyncStackDeleteV1 = {
/** Stack ID */
@ -7569,7 +7607,10 @@ export enum SyncEntityType {
StackV1 = "StackV1",
StackDeleteV1 = "StackDeleteV1",
PersonV1 = "PersonV1",
PersonV2 = "PersonV2",
PersonDeleteV1 = "PersonDeleteV1",
PersonUserV1 = "PersonUserV1",
PersonUserDeleteV1 = "PersonUserDeleteV1",
AssetFaceV1 = "AssetFaceV1",
AssetFaceV2 = "AssetFaceV2",
AssetFaceDeleteV1 = "AssetFaceDeleteV1",
@ -7604,6 +7645,8 @@ export enum SyncRequestType {
StacksV1 = "StacksV1",
UsersV1 = "UsersV1",
PeopleV1 = "PeopleV1",
PeopleV2 = "PeopleV2",
PersonUsersV1 = "PersonUsersV1",
AssetFacesV1 = "AssetFacesV1",
AssetFacesV2 = "AssetFacesV2",
UserMetadataV1 = "UserMetadataV1"

View file

@ -16,7 +16,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { VideoInterfaces } from 'src/types';
@ -25,6 +25,7 @@ import { getConfig } from 'src/utils/config';
export interface MoveRequest {
entityId: string;
ownerId: string;
pathType: PathType;
oldPath: string | null;
newPath: string;
@ -52,7 +53,7 @@ export class StorageCore {
private configRepository: ConfigRepository,
private cryptoRepository: CryptoRepository,
private moveRepository: MoveRepository,
private personRepository: PersonRepository,
private personUserRepository: PersonUserRepository,
private storageRepository: StorageRepository,
private systemMetadataRepository: SystemMetadataRepository,
private logger: LoggingRepository,
@ -65,7 +66,7 @@ export class StorageCore {
configRepository: ConfigRepository,
cryptoRepository: CryptoRepository,
moveRepository: MoveRepository,
personRepository: PersonRepository,
personUserRepository: PersonUserRepository,
storageRepository: StorageRepository,
systemMetadataRepository: SystemMetadataRepository,
logger: LoggingRepository,
@ -76,7 +77,7 @@ export class StorageCore {
configRepository,
cryptoRepository,
moveRepository,
personRepository,
personUserRepository,
storageRepository,
systemMetadataRepository,
logger,
@ -161,6 +162,7 @@ export class StorageCore {
const oldFile = getAssetFile(files, fileType, { isEdited: false });
return this.moveFile({
entityId,
ownerId: asset.ownerId,
pathType: fileType,
oldPath: oldFile?.path || null,
newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
@ -171,6 +173,7 @@ export class StorageCore {
const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
return this.moveFile({
entityId: asset.id,
ownerId: asset.ownerId,
pathType: AssetPathType.EncodedVideo,
oldPath: encodedVideoFile?.path || null,
newPath: StorageCore.getEncodedVideoPath(asset),
@ -183,6 +186,7 @@ export class StorageCore {
case PersonPathType.Face: {
await this.moveFile({
entityId,
ownerId: person.ownerId,
pathType,
oldPath: thumbnailPath,
newPath: StorageCore.getPersonThumbnailPath(person),
@ -192,7 +196,7 @@ export class StorageCore {
}
async moveFile(request: MoveRequest) {
const { entityId, pathType, oldPath, newPath, assetInfo } = request;
const { entityId, ownerId, pathType, oldPath, newPath, assetInfo } = request;
if (!oldPath || oldPath === newPath) {
return;
}
@ -265,7 +269,7 @@ export class StorageCore {
}
}
await this.savePath(pathType, entityId, newPath);
await this.savePath(pathType, entityId, ownerId, newPath);
await this.moveRepository.delete(move.id);
}
@ -318,7 +322,7 @@ export class StorageCore {
return { dri, mali };
}
private savePath(pathType: PathType, id: string, newPath: string) {
private savePath(pathType: PathType, id: string, ownerId: string, newPath: string) {
switch (pathType) {
case AssetPathType.Original: {
return this.assetRepository.update({ id, originalPath: newPath });
@ -334,7 +338,7 @@ export class StorageCore {
}
case PersonPathType.Face: {
return this.personRepository.update({ id, thumbnailPath: newPath });
return this.personUserRepository.update({ personId: id, ownerId, thumbnailPath: newPath });
}
case UserPathType.Profile: {

View file

@ -26,6 +26,7 @@ export type AuthUser = {
email: string;
quotaUsageInBytes: number;
quotaSizeInBytes: number | null;
trustedGroupId: string;
};
export type AlbumUser = {
@ -130,6 +131,7 @@ export type User = {
avatarColor: UserAvatarColor | null;
profileImagePath: string;
profileChangedAt: Date;
trustedGroupId: string;
};
export type UserAdmin = User & {
@ -249,7 +251,7 @@ export type Person = {
name: string;
birthDate: Date | null;
color: string | null;
faceAssetId: string | null;
thumbnailFaceAssetId: string | null;
isHidden: boolean;
thumbnailPath: string;
};
@ -274,7 +276,15 @@ export type AssetFace = {
export type Plugin = Selectable<PluginTable>;
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userColumns = [
'id',
'name',
'email',
'avatarColor',
'profileImagePath',
'profileChangedAt',
'trustedGroupId',
] as const;
const userWithPrefixColumns = [
'user2.id',
'user2.name',
@ -282,6 +292,7 @@ const userWithPrefixColumns = [
'user2.avatarColor',
'user2.profileImagePath',
'user2.profileChangedAt',
'user2.trustedGroupId',
] as const;
export const columns = {
@ -369,7 +380,15 @@ export const columns = {
'asset_file.isProgressive',
'asset_file.isTransparent',
],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authUser: [
'user.id',
'user.name',
'user.email',
'user.isAdmin',
'user.quotaUsageInBytes',
'user.quotaSizeInBytes',
'user.trustedGroupId',
],
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
user: userColumns,

View file

@ -171,7 +171,12 @@ const PeopleResponseSchema = z
.describe('People response');
export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {}
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
export function mapPerson(
person: Pick<
MaybeDehydrated<Person>,
'id' | 'name' | 'birthDate' | 'thumbnailPath' | 'isHidden' | 'isFavorite' | 'color' | 'updatedAt'
>,
): PersonResponseDto {
return {
id: person.id,
name: person.name,

View file

@ -346,10 +346,43 @@ const SyncPersonV1Schema = z
})
.meta({ id: 'SyncPersonV1' });
const SyncPersonV2Schema = SyncPersonV1Schema.omit({
ownerId: true,
isHidden: true,
isFavorite: true,
faceAssetId: true,
})
.extend({ trustedGroupId: z.uuidv4().describe('Trusted group ID') })
.meta({ id: 'SyncPersonV2' });
export function syncPersonV2ToV1(
personV2: SyncPersonV2,
personUser: { ownerId: string; isHidden: boolean; isFavorite: boolean; thumbnailFaceAssetId: string | null },
): SyncPersonV1 {
const { ownerId, isHidden, isFavorite, thumbnailFaceAssetId: faceAssetId } = personUser;
return { ...personV2, ownerId, isHidden, isFavorite, faceAssetId };
}
const SyncPersonDeleteV1Schema = z
.object({ personId: z.uuidv4().describe('Person ID') })
.meta({ id: 'SyncPersonDeleteV1' });
const SyncPersonUserV1Schema = z
.object({
personId: z.uuidv4().describe('Person ID'),
ownerId: z.uuidv4().describe('Owner ID'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
isHidden: z.boolean().describe('Is hidden'),
isFavorite: z.boolean().describe('Is favorite'),
thumbnailFaceAssetId: z.uuidv4().nullable().describe('Face asset ID'),
})
.meta({ id: 'SyncPersonUserV1' });
const SyncPersonUserDeleteV1Schema = z
.object({ personId: z.uuidv4().describe('Person ID'), ownerId: z.uuidv4().describe('Owner ID') })
.meta({ id: 'SyncPersonUserDeleteV1' });
const SyncAssetFaceV1Schema = z
.object({
id: z.uuidv4().describe('Asset face ID'),
@ -443,8 +476,14 @@ class SyncStackDeleteV1 extends createZodDto(SyncStackDeleteV1Schema) {}
@ExtraModel()
class SyncPersonV1 extends createZodDto(SyncPersonV1Schema) {}
@ExtraModel()
class SyncPersonV2 extends createZodDto(SyncPersonV2Schema) {}
@ExtraModel()
class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {}
@ExtraModel()
class SyncPersonUserV1 extends createZodDto(SyncPersonUserV1Schema) {}
@ExtraModel()
class SyncPersonUserDeleteV1 extends createZodDto(SyncPersonUserDeleteV1Schema) {}
@ExtraModel()
class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {}
@ExtraModel()
class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {}
@ -506,7 +545,10 @@ export type SyncItem = {
[SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1;
[SyncEntityType.PartnerStackV1]: SyncStackV1;
[SyncEntityType.PersonV1]: SyncPersonV1;
[SyncEntityType.PersonV2]: SyncPersonV2;
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
[SyncEntityType.PersonUserV1]: SyncPersonUserV1;
[SyncEntityType.PersonUserDeleteV1]: SyncPersonUserDeleteV1;
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;

View file

@ -1015,6 +1015,8 @@ export enum SyncRequestType {
StacksV1 = 'StacksV1',
UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1',
PeopleV2 = 'PeopleV2',
PersonUsersV1 = 'PersonUsersV1',
/** @deprecated */
AssetFacesV1 = 'AssetFacesV1',
AssetFacesV2 = 'AssetFacesV2',
@ -1095,8 +1097,12 @@ export enum SyncEntityType {
StackDeleteV1 = 'StackDeleteV1',
PersonV1 = 'PersonV1',
PersonV2 = 'PersonV2',
PersonDeleteV1 = 'PersonDeleteV1',
PersonUserV1 = 'PersonUserV1',
PersonUserDeleteV1 = 'PersonUserDeleteV1',
AssetFaceV1 = 'AssetFaceV1',
AssetFaceV2 = 'AssetFaceV2',
AssetFaceDeleteV1 = 'AssetFaceDeleteV1',

View file

@ -189,12 +189,12 @@ where
-- AccessRepository.person.checkOwnerAccess
select
"person"."id"
"person_user"."personId"
from
"person"
"person_user"
where
"person"."id" in ($1)
and "person"."ownerId" = $2
"person_user"."personId" in ($1)
and "person_user"."ownerId" = $2
-- AccessRepository.person.checkFaceOwnerAccess
select

View file

@ -15,7 +15,8 @@ from
"user2"."email",
"user2"."avatarColor",
"user2"."profileImagePath",
"user2"."profileChangedAt"
"user2"."profileChangedAt",
"user2"."trustedGroupId"
from
(
select
@ -47,7 +48,8 @@ returning
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user"
where

View file

@ -30,7 +30,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
(
select
@ -109,7 +110,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
(
select
@ -206,7 +208,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
(
select
@ -379,7 +382,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
(
select

View file

@ -15,7 +15,8 @@ select
"user"."email",
"user"."isAdmin",
"user"."quotaUsageInBytes",
"user"."quotaSizeInBytes"
"user"."quotaSizeInBytes",
"user"."trustedGroupId"
from
"user"
where

View file

@ -354,9 +354,11 @@ select
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $2
) as agg
) as "files"
) as "files",
"user"."trustedGroupId" as "ownerTrustedGroupId"
from
"asset"
inner join "user" on "user"."id" = "asset"."ownerId"
where
"asset"."id" = $3

View file

@ -188,9 +188,16 @@ select
"asset_face"
left join lateral (
select
"person".*
"person".*,
"person_user"."ownerId",
"person_user"."thumbnailFaceAssetId",
"person_user"."thumbnailPath",
"person_user"."isHidden",
"person_user"."isFavorite"
from
"person"
inner join "person_user" on "person_user"."personId" = "person"."id"
and "person_user"."ownerId" = "asset"."ownerId"
where
"asset_face"."personId" = "person"."id"
) as "person" on true

View file

@ -59,11 +59,11 @@ where
-- IntegrityRepository.getPersonThumbnailPathsByPaths
select
"person"."thumbnailPath"
"person_user"."thumbnailPath"
from
"person"
"person_user"
where
"person"."thumbnailPath" in $1
"person_user"."thumbnailPath" in $1
-- IntegrityRepository.getAssetCount
select

View file

@ -47,10 +47,11 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
inner join "person_user" on "person_user"."personId" = "asset_face"."personId"
and "person_user"."ownerId" = $2
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
and "person_user"."isHidden" = $3
)
order by
"asset"."fileCreatedAt" asc
@ -61,7 +62,7 @@ from
"memory"
where
"deletedAt" is null
and "ownerId" = $3
and "ownerId" = $4
order by
"memoryAt" desc
@ -86,10 +87,11 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
inner join "person_user" on "person_user"."personId" = "asset_face"."personId"
and "person_user"."ownerId" = $2
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
and "person_user"."isHidden" = $3
)
order by
"asset"."fileCreatedAt" asc
@ -101,14 +103,14 @@ from
where
(
"showAt" is null
or "showAt" <= $3
or "showAt" <= $4
)
and (
"hideAt" is null
or "hideAt" >= $4
or "hideAt" >= $5
)
and "deletedAt" is null
and "ownerId" = $5
and "ownerId" = $6
order by
"memoryAt" desc

View file

@ -14,7 +14,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user" as "sharedBy"
where
@ -32,7 +33,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user" as "sharedWith"
where
@ -65,7 +67,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user" as "sharedBy"
where
@ -83,7 +86,8 @@ select
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user" as "sharedWith"
where
@ -120,7 +124,8 @@ returning
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user" as "sharedBy"
where
@ -138,7 +143,8 @@ returning
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user" as "sharedWith"
where

View file

@ -14,10 +14,10 @@ where
-- PersonRepository.getFileSamples
select
"id",
"personId",
"thumbnailPath"
from
"person"
"person_user"
where
"thumbnailPath" != ''
limit
@ -25,20 +25,26 @@ limit
-- PersonRepository.getAllForUser
select
"person".*
"person".*,
"person_user"."thumbnailPath",
"person_user"."isFavorite",
"person_user"."isHidden"
from
"person"
inner join "person_user" on "person_user"."personId" = "person"."id"
and "person_user"."ownerId" = $1
inner join "asset_face" on "asset_face"."personId" = "person"."id"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
"person"."ownerId" = $1
and "asset_face"."deletedAt" is null
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "person"."isHidden" = $2
and "person_user"."isHidden" = $2
group by
"person"."id"
"person"."id",
"person_user"."personId",
"person_user"."ownerId"
having
(
"person"."name" != $3
@ -56,8 +62,8 @@ having
)::int
)
order by
"person"."isHidden" asc,
"person"."isFavorite" desc,
"person_user"."isHidden" asc,
"person_user"."isFavorite" desc,
NULLIF(person.name, '') is null asc,
count("asset_face"."assetId") desc,
NULLIF(person.name, '') asc nulls last,
@ -67,20 +73,6 @@ limit
offset
$6
-- PersonRepository.getAllWithoutFaces
select
"person".*
from
"person"
left join "asset_face" on "asset_face"."personId" = "person"."id"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
group by
"person"."id"
having
count("asset_face"."assetId") = $1
-- PersonRepository.getFaces
select
"asset_face".*,
@ -90,19 +82,25 @@ select
from
(
select
"person".*
"person".*,
"person_user"."ownerId",
"person_user"."thumbnailFaceAssetId",
"person_user"."thumbnailPath",
"person_user"."isHidden",
"person_user"."isFavorite"
from
"person"
inner join "person_user" on "person_user"."personId" = "person"."id"
where
"person"."id" = "asset_face"."personId"
and "person_user"."ownerId" = $1
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."assetId" = $1
"asset_face"."assetId" = $2
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
order by
"asset_face"."boundingBoxX1" asc
@ -115,17 +113,24 @@ select
from
(
select
"person".*
"person".*,
"person_user"."ownerId",
"person_user"."thumbnailFaceAssetId",
"person_user"."thumbnailPath",
"person_user"."isHidden",
"person_user"."isFavorite"
from
"person"
inner join "person_user" on "person_user"."personId" = "person"."id"
where
"person"."id" = "asset_face"."personId"
and "person_user"."ownerId" = $1
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."id" = $1
"asset_face"."id" = $2
and "asset_face"."deletedAt" is null
-- PersonRepository.getFaceForFacialRecognitionJob
@ -167,37 +172,6 @@ where
"asset_face"."id" = $1
and "asset_face"."deletedAt" is null
-- PersonRepository.getDataForThumbnailGenerationJob
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
"asset_file"."path"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = 'preview'
and "asset_file"."isEdited" = false
) as "previewPath"
from
"person"
inner join "asset_face" on "asset_face"."id" = "person"."faceAssetId"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
where
"person"."id" = $1
and "asset_face"."deletedAt" is null
-- PersonRepository.reassignFace
update "asset_face"
set
@ -205,6 +179,28 @@ set
where
"asset_face"."id" = $2
-- PersonRepository.getById
select
"person".*
from
"person"
where
"person"."id" = $1
-- PersonRepository.getForOwner
select
"person_user".*,
"person"."id",
"person"."birthDate",
"person"."color",
"person"."name"
from
"person_user"
inner join "person" on "person"."id" = "person_user"."personId"
where
"person_user"."personId" = $1
and "person_user"."ownerId" = $2
-- PersonRepository.getByName
with
"similarity_threshold" as (
@ -212,13 +208,17 @@ with
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select
"person".*
"person".*,
"person_user"."thumbnailPath",
"person_user"."isFavorite",
"person_user"."isHidden"
from
"similarity_threshold",
"person"
inner join "person_user" on "person_user"."personId" = "person"."id"
and "person_user"."ownerId" = $1
where
"person"."ownerId" = $1
and f_unaccent ("person"."name") %> f_unaccent ($2)
f_unaccent ("person"."name") %> f_unaccent ($2)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3)
limit
@ -230,11 +230,10 @@ select distinct
"person"."name"
from
"person"
inner join "person_user" on "person_user"."personId" = "person"."id"
and "person_user"."ownerId" = $1
where
(
"person"."ownerId" = $1
and "person"."name" != $2
)
"person"."name" != $2
-- PersonRepository.getStatistics
select
@ -249,39 +248,6 @@ where
and "asset_face"."isVisible" is true
and "asset_face"."personId" = $1
-- PersonRepository.getNumberOfPeople
select
coalesce(count(*), 0) as "total",
coalesce(
count(*) filter (
where
"isHidden" = $1
),
0
) as "hidden"
from
"person"
where
exists (
select
from
"asset_face"
where
"asset_face"."personId" = "person"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and exists (
select
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
)
)
and "person"."ownerId" = $3
-- PersonRepository.refreshFaces
with
"added_embeddings" as (
@ -306,9 +272,15 @@ select
from
(
select
"person".*
"person".*,
"person_user"."ownerId",
"person_user"."thumbnailFaceAssetId",
"person_user"."thumbnailPath",
"person_user"."isHidden",
"person_user"."isFavorite"
from
"person"
inner join "person_user" on "person_user"."personId" = "person"."id"
where
"person"."id" = "asset_face"."personId"
) as obj
@ -348,15 +320,6 @@ set
where
"asset_face"."id" = $2
-- PersonRepository.getForPeopleDelete
select
"id",
"thumbnailPath"
from
"person"
where
"id" in ($1)
-- PersonRepository.getForFeatureFaceUpdate
select
"asset_face"."id"

View file

@ -0,0 +1,132 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PersonUserRepository.create
insert into
"person_user" ("ownerId", "personId")
values
($1, $2)
returning
*
-- PersonUserRepository.createAll
insert into
"person_user" ("ownerId", "personId")
values
($1, $2)
returning
*
-- PersonUserRepository.update
update "person_user"
set
"ownerId" = $1,
"personId" = $2
where
"ownerId" = $3
and "personId" = $4
returning
*
-- PersonUserRepository.delete
delete from "person_user"
where
"ownerId" = $1
and "personId" = $2
-- PersonUserRepository.getForPeopleDelete
select
"personId",
"thumbnailPath"
from
"person_user"
where
"personId" in ($1)
-- PersonUserRepository.getNumberOfPeople
select
coalesce(count(*), 0) as "total",
coalesce(
count(*) filter (
where
"person_user"."isHidden" = $1
),
0
) as "hidden"
from
"person_user"
where
exists (
select
from
"asset_face"
where
"asset_face"."personId" = "person_user"."personId"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and exists (
select
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
)
)
and "person_user"."ownerId" = $3
-- PersonUserRepository.getDataForThumbnailGenerationJob
select
"person_user"."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
"asset_file"."path"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = 'preview'
and "asset_file"."isEdited" = false
) as "previewPath"
from
"person_user"
inner join "asset_face" on "asset_face"."id" = "person_user"."thumbnailFaceAssetId"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
where
"person_user"."personId" = $1
and "person_user"."ownerId" = $2
and "asset_face"."deletedAt" is null
-- PersonUserRepository.getAllWithoutFaces
select
"person_user"."personId",
"person_user"."thumbnailPath"
from
"person_user"
left join "asset_face" on "asset_face"."personId" = "person_user"."personId"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is not false
group by
"person_user"."personId",
"person_user"."ownerId"
having
count("asset_face"."assetId") = $1
-- PersonUserRepository.getThumbnailsForPerson
select
"thumbnailPath"
from
"person_user"
where
"personId" = $1

View file

@ -224,9 +224,10 @@ with
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
inner join "user" on "user"."id" = "asset"."ownerId"
left join "person" on "person"."id" = "asset_face"."personId"
where
"asset"."ownerId" = any ($2::uuid[])
"user"."trustedGroupId" = ($2)
and "asset"."deletedAt" is null
order by
"distance"

View file

@ -36,7 +36,8 @@ select
"user"."email",
"user"."isAdmin",
"user"."quotaUsageInBytes",
"user"."quotaSizeInBytes"
"user"."quotaSizeInBytes",
"user"."trustedGroupId"
from
"user"
where

View file

@ -135,7 +135,8 @@ from
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user"
where
@ -205,7 +206,8 @@ from
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"profileChangedAt",
"trustedGroupId"
from
"user"
where
@ -255,7 +257,8 @@ select
"user"."email",
"user"."isAdmin",
"user"."quotaUsageInBytes",
"user"."quotaSizeInBytes"
"user"."quotaSizeInBytes",
"user"."trustedGroupId"
from
"user"
where
@ -294,7 +297,8 @@ select
"user"."email",
"user"."isAdmin",
"user"."quotaUsageInBytes",
"user"."quotaSizeInBytes"
"user"."quotaSizeInBytes",
"user"."trustedGroupId"
from
"user"
where

View file

@ -1035,7 +1035,14 @@ from
where
"person_audit"."id" < $1
and "person_audit"."id" > $2
and "ownerId" = $3
and "trustedGroupId" = (
select
"trustedGroupId"
from
"user"
where
"user"."id" = $3
)
order by
"person_audit"."id" asc
@ -1044,23 +1051,60 @@ select
"id",
"createdAt",
"updatedAt",
"ownerId",
"name",
"birthDate",
"isHidden",
"isFavorite",
"color",
"updateId",
"faceAssetId"
"trustedGroupId"
from
"person" as "person"
where
"person"."updateId" < $1
and "person"."updateId" > $2
and "ownerId" = $3
and "trustedGroupId" = (
select
"trustedGroupId"
from
"user"
where
"user"."id" = $3
)
order by
"person"."updateId" asc
-- SyncRepository.personUser.getDeletes
select
"id",
"personId",
"ownerId"
from
"person_user_audit" as "person_user_audit"
where
"person_user_audit"."id" < $1
and "person_user_audit"."id" > $2
and "ownerId" = $3
order by
"person_user_audit"."id" asc
-- SyncRepository.personUser.getUpserts
select
"personId",
"ownerId",
"createdAt",
"updatedAt",
"isHidden",
"isFavorite",
"thumbnailFaceAssetId",
"updateId"
from
"person_user" as "person_user"
where
"person_user"."updateId" < $1
and "person_user"."updateId" > $2
and "ownerId" = $3
order by
"person_user"."updateId" asc
-- SyncRepository.stack.getDeletes
select
"id",

View file

@ -8,6 +8,7 @@ select
"avatarColor",
"profileImagePath",
"profileChangedAt",
"trustedGroupId",
"createdAt",
"updatedAt",
"deletedAt",
@ -47,6 +48,7 @@ select
"avatarColor",
"profileImagePath",
"profileChangedAt",
"trustedGroupId",
"createdAt",
"updatedAt",
"deletedAt",
@ -126,6 +128,7 @@ select
"avatarColor",
"profileImagePath",
"profileChangedAt",
"trustedGroupId",
"createdAt",
"updatedAt",
"deletedAt",
@ -165,6 +168,7 @@ select
"avatarColor",
"profileImagePath",
"profileChangedAt",
"trustedGroupId",
"createdAt",
"updatedAt",
"deletedAt",
@ -190,6 +194,7 @@ select
"avatarColor",
"profileImagePath",
"profileChangedAt",
"trustedGroupId",
"createdAt",
"updatedAt",
"deletedAt",
@ -237,6 +242,7 @@ select
"avatarColor",
"profileImagePath",
"profileChangedAt",
"trustedGroupId",
"createdAt",
"updatedAt",
"deletedAt",
@ -275,6 +281,7 @@ select
"avatarColor",
"profileImagePath",
"profileChangedAt",
"trustedGroupId",
"createdAt",
"updatedAt",
"deletedAt",

View file

@ -425,18 +425,18 @@ class PersonAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, personIds: Set<string>) {
checkOwnerAccess(userId: string, personIds: Set<string>) {
if (personIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('person')
.select('person.id')
.where('person.id', 'in', [...personIds])
.where('person.ownerId', '=', userId)
.selectFrom('person_user')
.select('person_user.personId')
.where('person_user.personId', 'in', [...personIds])
.where('person_user.ownerId', '=', userId)
.execute()
.then((persons) => new Set(persons.map((person) => person.id)));
.then((people) => new Set(people.map((person) => person.personId)));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })

View file

@ -152,6 +152,8 @@ export class AssetJobRepository {
.select(withFaces)
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.id', '=', id)
.innerJoin('user', 'user.id', 'asset.ownerId')
.select('user.trustedGroupId as ownerTrustedGroupId')
.executeTakeFirst();
}

View file

@ -429,7 +429,7 @@ export class DatabaseRepository {
.execute();
await tx
.updateTable('person')
.updateTable('person_user')
.set((eb) => ({ thumbnailPath: eb.fn('REGEXP_REPLACE', ['thumbnailPath', source, target]) }))
.execute();

View file

@ -29,6 +29,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
@ -84,6 +85,7 @@ export const repositories = [
OAuthRepository,
OcrRepository,
PartnerRepository,
PersonUserRepository,
PersonRepository,
PluginRepository,
ProcessRepository,

View file

@ -88,9 +88,9 @@ export class IntegrityRepository {
@GenerateSql({ params: [DummyValue.STRING] })
getPersonThumbnailPathsByPaths(paths: string[]) {
return this.db
.selectFrom('person')
.select('person.thumbnailPath')
.where('person.thumbnailPath', 'in', paths)
.selectFrom('person_user')
.select('person_user.thumbnailPath')
.where('person_user.thumbnailPath', 'in', paths)
.execute();
}

View file

@ -73,10 +73,14 @@ export class MemoryRepository implements IBulkAsset {
eb.exists(
eb
.selectFrom('asset_face')
.innerJoin('person', 'person.id', 'asset_face.personId')
.innerJoin('person_user', (join) =>
join
.onRef('person_user.personId', '=', 'asset_face.personId')
.on('person_user.ownerId', '=', ownerId),
)
.select((eb) => eb.val(1).as('one'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('person.isHidden', '=', true),
.where('person_user.isHidden', '=', true),
),
),
)

View file

@ -0,0 +1,191 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole, AssetFileType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { PersonUserTable } from 'src/schema/tables/person-user.table';
import { removeUndefinedKeys, withFilePath } from 'src/utils/database';
type PersonUserId = {
personId: string;
ownerId: string;
};
export type GetAllPeopleOptions = {
ownerId?: string;
thumbnailPath?: string;
faceAssetId?: string | null;
isHidden?: boolean;
};
@Injectable()
export class PersonUserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
getAll(options: GetAllPeopleOptions = {}) {
return this.db
.selectFrom('person_user')
.selectAll('person_user')
.$if(!!options.ownerId, (qb) => qb.where('person_user.ownerId', '=', options.ownerId!))
.$if(options.thumbnailPath !== undefined, (qb) =>
qb.where('person_user.thumbnailPath', '=', options.thumbnailPath!),
)
.$if(options.faceAssetId === null, (qb) => qb.where('person_user.thumbnailFaceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person_user.thumbnailFaceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person_user.isHidden', '=', options.isHidden!))
.stream();
}
get({ personId, ownerId }: PersonUserId) {
return this.db
.selectFrom('person_user')
.selectAll()
.where('person_user.personId', '=', personId)
.where('person_user.ownerId', '=', ownerId)
.executeTakeFirst();
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }] })
create(personUser: Insertable<PersonUserTable>) {
return this.db.insertInto('person_user').values(personUser).returningAll().executeTakeFirstOrThrow();
}
@GenerateSql({ params: [[{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }]] })
async createAll(personUsers: Insertable<PersonUserTable>[]) {
if (personUsers.length === 0) {
return;
}
await this.db.insertInto('person_user').values(personUsers).returningAll().execute();
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
update(dto: Updateable<PersonUserTable> & PersonUserId) {
return this.db
.updateTable('person_user')
.set(dto)
.where('ownerId', '=', dto.ownerId)
.where('personId', '=', dto.personId)
.returningAll()
.executeTakeFirstOrThrow();
}
async updateAll(personUsers: Array<Updateable<PersonUserTable> & PersonUserId>) {
if (personUsers.length === 0) {
return;
}
await this.db
.insertInto('person_user')
.values(personUsers)
.onConflict((oc) =>
oc.columns(['personId', 'ownerId']).doUpdateSet((eb) =>
removeUndefinedKeys(
{
isFavorite: eb.ref('excluded.isFavorite'),
isHidden: eb.ref('excluded.isHidden'),
thumbnailFaceAssetId: eb.ref('excluded.thumbnailFaceAssetId'),
thumbnailPath: eb.ref('excluded.thumbnailPath'),
},
personUsers[0],
),
),
)
.execute();
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, personId: DummyValue.UUID }] })
async delete({ ownerId, personId }: PersonUserId): Promise<void> {
await this.db.deleteFrom('person_user').where('ownerId', '=', ownerId).where('personId', '=', personId).execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
getForPeopleDelete(ids: string[]) {
if (ids.length === 0) {
return Promise.resolve([]);
}
return this.db
.selectFrom('person_user')
.select(['personId', 'thumbnailPath'])
.where('personId', 'in', ids)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getNumberOfPeople(userId: string) {
const zero = sql.lit(0);
return this.db
.selectFrom('person_user')
.where((eb) =>
eb.exists((eb) =>
eb
.selectFrom('asset_face')
.whereRef('asset_face.personId', '=', 'person_user.personId')
.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_user.ownerId', '=', userId)
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
.select((eb) =>
eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('person_user.isHidden', '=', true), zero).as('hidden'),
)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ personId: DummyValue.UUID, ownerId: DummyValue.UUID }] })
getDataForThumbnailGenerationJob({ personId, ownerId }: PersonUserId) {
return this.db
.selectFrom('person_user')
.innerJoin('asset_face', 'asset_face.id', 'person_user.thumbnailFaceAssetId')
.innerJoin('asset', 'asset_face.assetId', 'asset.id')
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.select([
'person_user.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_user.personId', '=', personId)
.where('person_user.ownerId', '=', ownerId)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirst();
}
@GenerateSql()
getAllWithoutFaces() {
return this.db
.selectFrom('person_user')
.select(['person_user.personId', 'person_user.thumbnailPath'])
.leftJoin('asset_face', 'asset_face.personId', 'person_user.personId')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is not', false)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
.groupBy('person_user.personId')
.groupBy('person_user.ownerId')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getThumbnailsForPerson(id: string) {
return this.db.selectFrom('person_user').select('thumbnailPath').where('personId', '=', id).execute();
}
}

View file

@ -4,12 +4,12 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum';
import { AssetVisibility, SourceType, UserMetadataKey } from 'src/enum';
import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { dummy, removeUndefinedKeys } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions {
@ -45,13 +45,6 @@ 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;
@ -62,9 +55,21 @@ export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>, ownerId?: string) => {
return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.id', '=', 'asset_face.personId')
.innerJoin('person_user', 'person_user.personId', 'person.id')
.$if(ownerId !== undefined, (qb) => qb.where('person_user.ownerId', '=', ownerId!))
.select([
'person_user.ownerId',
'person_user.thumbnailFaceAssetId',
'person_user.thumbnailPath',
'person_user.isHidden',
'person_user.isFavorite',
]),
).as('person');
};
@ -125,23 +130,11 @@ export class PersonRepository {
.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(['id', 'thumbnailPath'])
.selectFrom('person_user')
.select(['personId', 'thumbnailPath'])
.where('thumbnailPath', '!=', sql.lit(''))
.limit(sql.lit(3))
.execute();
@ -152,6 +145,10 @@ export class PersonRepository {
const items = await this.db
.selectFrom('person')
.selectAll('person')
.innerJoin('person_user', (join) =>
join.onRef('person_user.personId', '=', 'person.id').on('person_user.ownerId', '=', userId),
)
.select(['person_user.thumbnailPath', 'person_user.isFavorite', 'person_user.isHidden'])
.innerJoin('asset_face', 'asset_face.personId', 'person.id')
.innerJoin('asset', (join) =>
join
@ -159,11 +156,10 @@ export class PersonRepository {
.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')
.orderBy('person_user.isHidden', 'asc')
.orderBy('person_user.isFavorite', 'desc')
.having((eb) =>
eb.or([
eb('person.name', '!=', ''),
@ -181,6 +177,8 @@ export class PersonRepository {
]),
)
.groupBy('person.id')
.groupBy('person_user.personId')
.groupBy('person_user.ownerId')
.$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) =>
eb(
@ -188,7 +186,7 @@ export class PersonRepository {
eb
.selectFrom('face_search')
.select('face_search.embedding')
.whereRef('face_search.faceId', '=', 'person.faceAssetId'),
.whereRef('face_search.faceId', '=', 'person_user.thumbnailFaceAssetId'),
'<=>',
(eb) =>
eb
@ -205,7 +203,7 @@ export class PersonRepository {
.orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast())
.orderBy('person.createdAt'),
)
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
.$if(!options?.withHidden, (qb) => qb.where('person_user.isHidden', '=', false))
.offset(pagination.skip ?? 0)
.limit(pagination.take + 1)
.execute();
@ -213,41 +211,25 @@ export class PersonRepository {
return paginationHelper(items, pagination.take);
}
@GenerateSql()
getAllWithoutFaces() {
return this.db
.selectFrom('person')
.selectAll('person')
.leftJoin('asset_face', 'asset_face.personId', 'person.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
.groupBy('person.id')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string, options?: { isVisible?: boolean }) {
const isVisible = options === undefined ? true : options.isVisible;
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getFaces(assetId: string, ownerId?: string) {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson)
.select((eb) => withPerson(eb, ownerId))
.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] })
getFaceById(id: string) {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getFaceById(id: string, ownerId: string) {
// TODO return null instead of find or fail
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson)
.select((eb) => withPerson(eb, ownerId))
.where('asset_face.id', '=', id)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
@ -272,31 +254,6 @@ export class PersonRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getDataForThumbnailGenerationJob(id: string) {
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.id', '=', id)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.db
@ -308,6 +265,7 @@ export class PersonRepository {
return Number(result.numChangedRows ?? 0);
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(personId: string) {
return this.db //
.selectFrom('person')
@ -316,6 +274,18 @@ export class PersonRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getForOwner(personId: string, ownerId: string) {
return this.db
.selectFrom('person_user')
.selectAll('person_user')
.where('person_user.personId', '=', personId)
.where('person_user.ownerId', '=', ownerId)
.innerJoin('person', 'person.id', 'person_user.personId')
.select(['person.id', 'person.birthDate', 'person.color', 'person.name'])
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
return this.db
@ -323,12 +293,15 @@ export class PersonRepository {
db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')),
)
.selectFrom(['similarity_threshold', 'person'])
.innerJoin('person_user', (join) =>
join.onRef('person_user.personId', '=', 'person.id').on('person_user.ownerId', '=', userId),
)
.selectAll('person')
.where('person.ownerId', '=', userId)
.select(['person_user.thumbnailPath', 'person_user.isFavorite', 'person_user.isHidden'])
.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))
.$if(!withHidden, (qb) => qb.where('person_user.isHidden', '=', false))
.execute();
}
@ -337,9 +310,12 @@ export class PersonRepository {
return this.db
.selectFrom('person')
.select(['person.id', 'person.name'])
.innerJoin('person_user', (join) =>
join.onRef('person_user.personId', '=', 'person.id').on('person_user.ownerId', '=', userId),
)
.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))
.where('person.name', '!=', '')
.$if(!withHidden, (qb) => qb.where('person_user.isHidden', '=', false))
.execute();
}
@ -364,35 +340,6 @@ export class PersonRepository {
};
}
@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.personId', '=', 'person.id')
.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();
}
@ -453,10 +400,6 @@ export class PersonRepository {
{
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],
@ -534,15 +477,6 @@ export class PersonRepository {
}
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
getForPeopleDelete(ids: string[]) {
if (ids.length === 0) {
return Promise.resolve([]);
}
return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute();
}
@GenerateSql({ params: [[], []] })
async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise<void> {
if (visible.length === 0 && hidden.length === 0) {

View file

@ -162,7 +162,9 @@ export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number };
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
export interface FaceEmbeddingSearch {
embedding: string;
trustedGroupId: string;
hasPerson?: boolean;
numResults: number;
maxDistance: number;
@ -334,14 +336,14 @@ export class SearchRepository {
@GenerateSql({
params: [
{
userIds: [DummyValue.UUID],
trustedGroupId: [DummyValue.UUID],
embedding: DummyValue.VECTOR,
numResults: 10,
maxDistance: 0.6,
},
],
})
searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
searchFaces({ trustedGroupId, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
if (!z.int().min(1).max(1000).safeParse(numResults).success) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
@ -359,8 +361,9 @@ export class SearchRepository {
])
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.innerJoin('user', 'user.id', 'asset.ownerId')
.leftJoin('person', 'person.id', 'asset_face.personId')
.where('asset.ownerId', '=', anyUuid(userIds))
.where('user.trustedGroupId', '=', trustedGroupId)
.where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
.$if(!!minBirthDate, (qb) =>

View file

@ -65,6 +65,7 @@ export class SyncRepository {
partnerAssetExif: PartnerAssetExifsSync;
partnerStack: PartnerStackSync;
person: PersonSync;
personUser: PersonUserSync;
stack: StackSync;
user: UserSync;
userMetadata: UserMetadataSync;
@ -89,6 +90,7 @@ export class SyncRepository {
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
this.partnerStack = new PartnerStackSync(this.db);
this.person = new PersonSync(this.db);
this.personUser = new PersonUserSync(this.db);
this.stack = new StackSync(this.db);
this.user = new UserSync(this.db);
this.userMetadata = new UserMetadataSync(this.db);
@ -423,7 +425,9 @@ class PersonSync extends BaseSync {
getDeletes(options: SyncQueryOptions) {
return this.auditQuery('person_audit', options)
.select(['id', 'personId'])
.where('ownerId', '=', options.userId)
.where('trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('trustedGroupId').where('user.id', '=', options.userId),
)
.stream();
}
@ -434,22 +438,61 @@ class PersonSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('person', options)
.select(['id', 'createdAt', 'updatedAt', 'name', 'birthDate', 'color', 'updateId', 'trustedGroupId'])
.where('trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('trustedGroupId').where('user.id', '=', options.userId),
)
.stream();
}
getPersonUser({ personId, ownerId }: { personId: string; ownerId: string }) {
return this.db
.selectFrom('person_user')
.select(['ownerId', 'isFavorite', 'isHidden', 'thumbnailFaceAssetId'])
.where('personId', '=', personId)
.where('ownerId', '=', ownerId)
.executeTakeFirst();
}
}
class PersonUserSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getDeletes(options: SyncQueryOptions) {
return this.auditQuery('person_user_audit', options)
.select(['id', 'personId', 'ownerId'])
.where('ownerId', '=', options.userId)
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('person_user_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('person_user', options)
.select([
'id',
'personId',
'ownerId',
'createdAt',
'updatedAt',
'ownerId',
'name',
'birthDate',
'isHidden',
'isFavorite',
'color',
'thumbnailFaceAssetId',
'updateId',
'faceAssetId',
])
.where('ownerId', '=', options.userId)
.stream();
}
getPersonUser({ personId, ownerId }: { personId: string; ownerId: string }) {
return this.db
.selectFrom('person_user')
.select(['ownerId', 'isFavorite', 'isHidden', 'thumbnailFaceAssetId'])
.where('personId', '=', personId)
.where('ownerId', '=', ownerId)
.executeTakeFirst();
}
}
class AssetFaceSync extends BaseSync {

View file

@ -198,8 +198,21 @@ export const person_delete_audit = registerFunction({
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO person_audit ("personId", "ownerId")
SELECT "id", "ownerId"
INSERT INTO person_audit ("personId", "trustedGroupId")
SELECT "id", "trustedGroupId"
FROM OLD;
RETURN NULL;
END`,
});
export const person_user_delete_audit = registerFunction({
name: 'person_user_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO person_user_audit ("personId", "ownerId")
SELECT "personId", "ownerId"
FROM OLD;
RETURN NULL;
END`,

View file

@ -20,6 +20,7 @@ import {
memory_delete_audit,
partner_delete_audit,
person_delete_audit,
person_user_delete_audit,
stack_delete_audit,
updated_at,
user_delete_audit,
@ -62,6 +63,8 @@ import { OcrSearchTable } from 'src/schema/tables/ocr-search.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
import { PersonUserAuditTable } from 'src/schema/tables/person-user-audit.table';
import { PersonUserTable } from 'src/schema/tables/person-user.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
import { PluginTable } from 'src/schema/tables/plugin.table';
@ -130,6 +133,8 @@ export class ImmichDatabase {
PartnerTable,
PersonTable,
PersonAuditTable,
PersonUserTable,
PersonUserAuditTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
@ -170,6 +175,7 @@ export class ImmichDatabase {
memory_asset_delete_audit,
stack_delete_audit,
person_delete_audit,
person_user_delete_audit,
user_metadata_audit,
asset_metadata_audit,
asset_face_audit,
@ -243,6 +249,8 @@ export interface DB {
person: PersonTable;
person_audit: PersonAuditTable;
person_user: PersonUserTable;
person_user_audit: PersonUserAuditTable;
session: SessionTable;
session_sync_checkpoint: SessionSyncCheckpointTable;

View file

@ -0,0 +1,178 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person_audit ("personId", "trustedGroupId")
SELECT "id", "trustedGroupId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION person_user_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person_user_audit ("personId", "ownerId")
SELECT "personId", "ownerId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
await sql`CREATE TABLE "person_user" (
"personId" uuid NOT NULL,
"ownerId" uuid NOT NULL,
"isHidden" boolean NOT NULL DEFAULT false,
"isFavorite" boolean NOT NULL DEFAULT false,
"thumbnailPath" character varying NOT NULL DEFAULT '',
"thumbnailFaceAssetId" uuid,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "person_user_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "person_user_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "person_user_thumbnailFaceAssetId_fkey" FOREIGN KEY ("thumbnailFaceAssetId") REFERENCES "asset_face" ("id") ON UPDATE NO ACTION ON DELETE SET NULL,
CONSTRAINT "person_user_pkey" PRIMARY KEY ("personId", "ownerId")
);`.execute(db);
await db
.insertInto('person_user')
.columns(['personId', 'ownerId', 'isHidden', 'isFavorite', 'thumbnailPath', 'thumbnailFaceAssetId', 'createdAt'])
.expression((eb) =>
eb
.selectFrom('person')
.select([
'person.id',
'person.ownerId',
'person.isHidden',
'person.isFavorite',
'person.thumbnailPath',
'person.faceAssetId',
'person.createdAt',
]),
)
.execute();
await sql`CREATE INDEX "person_user_personId_idx" ON "person_user" ("personId");`.execute(db);
await sql`CREATE INDEX "person_user_ownerId_idx" ON "person_user" ("ownerId");`.execute(db);
await sql`CREATE INDEX "person_user_thumbnailFaceAssetId_idx" ON "person_user" ("thumbnailFaceAssetId");`.execute(db);
await sql`CREATE INDEX "person_user_updateId_idx" ON "person_user" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_user_delete_audit"
AFTER DELETE ON "person_user"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION person_user_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_user_updatedAt"
BEFORE UPDATE ON "person_user"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_ownerId_fkey";`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceAssetId_fkey";`.execute(db);
await sql`ALTER TABLE "person" RENAME COLUMN "faceAssetId" TO "trustedGroupId";`.execute(db);
await db
.updateTable('person')
.from('user')
.set((eb) => ({ trustedGroupId: eb.ref('user.trustedGroupId') }))
.whereRef('person.ownerId', '=', 'user.id')
.execute();
await sql`ALTER TABLE "person" DROP COLUMN "ownerId";`.execute(db);
await sql`ALTER TABLE "person" DROP COLUMN "isHidden";`.execute(db);
await sql`ALTER TABLE "person" DROP COLUMN "isFavorite";`.execute(db);
await sql`ALTER TABLE "person" DROP COLUMN "thumbnailPath";`.execute(db);
await sql`CREATE INDEX "person_trustedGroupId_idx" ON "person" ("trustedGroupId");`.execute(db);
await sql`DROP INDEX "person_faceAssetId_idx";`.execute(db);
await sql`CREATE TABLE "person_user_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"personId" uuid NOT NULL,
"ownerId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "person_user_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await db
.insertInto('person_user_audit')
.columns(['personId', 'ownerId', 'deletedAt'])
.expression((eb) => eb.selectFrom('person_audit').select(['person_audit.personId', 'person_audit.ownerId', 'person_audit.deletedAt']))
.execute();
await sql`ALTER TABLE "person_audit" RENAME COLUMN "ownerId" TO "trustedGroupId";`.execute(db);
await db
.updateTable('person_audit')
.from('user')
.set((eb) => ({ trustedGroupId: eb.ref('user.trustedGroupId') }))
.whereRef('person_audit.trustedGroupId', '=', 'user.id')
.execute();
await sql`CREATE INDEX "person_audit_trustedGroupId_idx" ON "person_audit" ("trustedGroupId");`.execute(db);
await sql`DROP INDEX "person_audit_ownerId_idx";`.execute(db);
await sql`CREATE INDEX "person_user_audit_personId_idx" ON "person_user_audit" ("personId");`.execute(db);
await sql`CREATE INDEX "person_user_audit_ownerId_idx" ON "person_user_audit" ("ownerId");`.execute(db);
await sql`CREATE INDEX "person_user_audit_deletedAt_idx" ON "person_user_audit" ("deletedAt");`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"trustedGroupId\\")\\n SELECT \\"id\\", \\"trustedGroupId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_user_delete_audit', '{"type":"function","name":"person_user_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_user_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_user_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"personId\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_user_delete_audit', '{"type":"trigger","name":"person_user_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_user_delete_audit\\"\\n AFTER DELETE ON \\"person_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION person_user_delete_audit();"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_user_updatedAt', '{"type":"trigger","name":"person_user_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_user_updatedAt\\"\\n BEFORE UPDATE ON \\"person_user\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(
db,
);
}
export async function down(_db: Kysely<any>): Promise<void> {
// TODO we probably won't support this?
//
// await sql`CREATE OR REPLACE FUNCTION public.person_delete_audit()
// RETURNS trigger
// LANGUAGE plpgsql
// AS $function$
// BEGIN
// INSERT INTO person_audit ("personId", "ownerId")
// SELECT "id", "ownerId"
// FROM OLD;
// RETURN NULL;
// END
// $function$
// `.execute(db);
// await sql`DROP TRIGGER "person_user_delete_audit" ON "person_user";`.execute(db);
// await sql`DROP FUNCTION person_user_delete_audit;`.execute(db);
// await sql`ALTER TABLE "person" RENAME COLUMN "trustedGroupId" TO "faceAssetId";`.execute(db);
// await sql`ALTER TABLE "person" ADD "ownerId" uuid NOT NULL;`.execute(db);
// await sql`ALTER TABLE "person" ADD "isHidden" boolean NOT NULL DEFAULT false;`.execute(db);
// await sql`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db);
// await sql`ALTER TABLE "person" ADD "thumbnailPath" character varying NOT NULL DEFAULT ''::character varying;`.execute(
// db,
// );
// await sql`CREATE INDEX "person_faceAssetId_idx" ON "person" ("faceAssetId");`.execute(db);
// await sql`CREATE INDEX "person_ownerId_idx" ON "person" ("ownerId");`.execute(db);
// await sql`DROP INDEX "person_trustedGroupId_idx";`.execute(db);
// await sql`ALTER TABLE "person" ADD CONSTRAINT "person_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(
// db,
// );
// await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceAssetId_fkey" FOREIGN KEY ("faceAssetId") REFERENCES "asset_face" ("id") ON UPDATE NO ACTION ON DELETE SET NULL;`.execute(
// db,
// );
// await sql`ALTER TABLE "person_audit" RENAME COLUMN "trustedGroupId" TO "ownerId";`.execute(db);
// await sql`CREATE INDEX "person_audit_ownerId_idx" ON "person_audit" ("ownerId");`.execute(db);
// await sql`DROP INDEX "person_audit_trustedGroupId_idx";`.execute(db);
// await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db);
// await sql`DROP TABLE "person_user_audit";`.execute(db);
// await sql`DROP TABLE "person_user";`.execute(db);
// await sql`DROP TRIGGER "person_user_updatedAt" ON "person_user";`.execute(db);
// await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"person_delete_audit","type":"function"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(
// db,
// );
// await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_user_delete_audit';`.execute(db);
// await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_delete_audit';`.execute(db);
// await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_user_updatedAt';`.execute(db);
}

View file

@ -10,7 +10,7 @@ export class PersonAuditTable {
personId!: string;
@Column({ type: 'uuid', index: true })
ownerId!: string;
trustedGroupId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;

View file

@ -0,0 +1,17 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
@Table('person_user_audit')
export class PersonUserAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
personId!: string;
@Column({ type: 'uuid', index: true })
ownerId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View file

@ -0,0 +1,52 @@
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_user_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person_user')
@UpdatedAtTrigger('person_user_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: person_user_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class PersonUserTable {
@ForeignKeyColumn(() => PersonTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
personId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
ownerId!: string;
@Column({ type: 'boolean', default: false })
isHidden!: Generated<boolean>;
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@Column({ default: '' })
thumbnailPath!: Generated<string>;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
thumbnailFaceAssetId!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View file

@ -3,7 +3,6 @@ import {
Check,
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
@ -13,8 +12,6 @@ import {
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@Index({
@ -40,30 +37,18 @@ export class PersonTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: Generated<string>;
@Column({ default: '' })
thumbnailPath!: Generated<string>;
@Column({ type: 'boolean', default: false })
isHidden!: Generated<boolean>;
@Column({ type: 'date', nullable: true })
birthDate!: Timestamp | null;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@Column({ type: 'character varying', nullable: true, default: null })
color!: string | null;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ type: 'uuid', index: true, nullable: true })
trustedGroupId!: string;
}

View file

@ -4,6 +4,7 @@ import {
CreateDateColumn,
DeleteDateColumn,
Generated,
GeneratedColumn,
Index,
PrimaryGeneratedColumn,
Table,
@ -82,4 +83,7 @@ export class UserTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@GeneratedColumn({})
trustedGroupId!: Generated<string>;
}

View file

@ -36,6 +36,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
@ -95,6 +96,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
OAuthRepository,
OcrRepository,
PartnerRepository,
PersonUserRepository,
PersonRepository,
PluginRepository,
ProcessRepository,
@ -155,6 +157,7 @@ export class BaseService {
protected oauthRepository: OAuthRepository,
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,
protected personUserRepository: PersonUserRepository,
protected personRepository: PersonRepository,
protected pluginRepository: PluginRepository,
protected processRepository: ProcessRepository,
@ -184,7 +187,7 @@ export class BaseService {
configRepository,
cryptoRepository,
moveRepository,
personRepository,
personUserRepository,
storageRepository,
systemMetadataRepository,
this.logger,
@ -224,6 +227,7 @@ export class BaseService {
ctx.oauthRepository,
ctx.ocrRepository,
ctx.partnerRepository,
ctx.personUserRepository,
ctx.personRepository,
ctx.pluginRepository,
ctx.processRepository,

View file

@ -50,7 +50,7 @@ describe(JobService.name, () => {
jobs: [],
},
{
item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } },
item: { name: JobName.PersonGenerateThumbnail, data: { personId: 'person-1', ownerId: 'user-1' } },
jobs: [],
},
{

View file

@ -124,10 +124,9 @@ export class JobService extends BaseService {
}
case JobName.PersonGenerateThumbnail: {
const { id } = item.data;
const person = await this.personRepository.getById(id);
if (person) {
this.websocketRepository.clientSend('on_person_thumbnail', person.ownerId, person.id);
const personUser = await this.personUserRepository.get(item.data);
if (personUser) {
this.websocketRepository.clientSend('on_person_thumbnail', personUser.ownerId, personUser.personId);
}
break;
}

View file

@ -24,12 +24,12 @@ import { MediaService } from 'src/services/media.service';
import { AudioStreamInfo, JobCounts, RawImageInfo, VideoFormat, VideoStreamInfo } from 'src/types';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { PersonUserFactory } from 'test/factories/person-user.factory';
import { probeStub } from 'test/fixtures/media.stub';
import { personThumbnailStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { getForGenerateThumbnail } from 'test/mappers';
import { factory, newUuid } from 'test/small.factory';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const fullsizeBuffer = Buffer.from('embedded image data');
@ -53,10 +53,9 @@ describe(MediaService.name, () => {
describe('handleQueueGenerateThumbnails', () => {
it('should queue all assets', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: newUuid() });
const personUser = PersonUserFactory.create();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream([person]));
mocks.personUser.getAll.mockReturnValue(makeStream([personUser]));
await sut.handleQueueGenerateThumbnails({ force: true });
@ -68,11 +67,11 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith(undefined);
expect(mocks.personUser.getAll).toHaveBeenCalledWith(undefined);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: person.id },
data: { personId: personUser.personId, ownerId: personUser.ownerId },
},
]);
});
@ -80,7 +79,7 @@ describe(MediaService.name, () => {
it('should queue trashed assets when force is true', async () => {
const asset = AssetFactory.create({ status: AssetStatus.Trashed, deletedAt: new Date() });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
@ -96,7 +95,7 @@ describe(MediaService.name, () => {
it('should queue archived assets when force is true', async () => {
const asset = AssetFactory.create({ visibility: AssetVisibility.Archive });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
@ -110,26 +109,27 @@ describe(MediaService.name, () => {
});
it('should queue all people with missing thumbnail path', async () => {
const [person1, person2] = [
PersonFactory.create({ thumbnailPath: undefined }),
PersonFactory.create({ thumbnailPath: undefined }),
const [personUser1, personUser2] = [
PersonUserFactory.create({ thumbnailPath: undefined, thumbnailFaceAssetId: null }),
PersonUserFactory.create({ thumbnailPath: undefined, thumbnailFaceAssetId: null }),
];
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()]));
mocks.person.getAll.mockReturnValue(makeStream([person1, person2]));
mocks.personUser.getAll.mockReturnValue(makeStream([personUser1, personUser2]));
mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.person.getRandomFace).toHaveBeenCalled();
expect(mocks.person.update).toHaveBeenCalledTimes(1);
expect(mocks.personUser.update).toHaveBeenCalledTimes(1);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: {
id: person1.id,
personId: personUser1.personId,
ownerId: personUser1.ownerId,
},
},
]);
@ -138,7 +138,7 @@ describe(MediaService.name, () => {
it('should queue all assets with missing resize path', async () => {
const asset = AssetFactory.create();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
@ -149,20 +149,20 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing preview', async () => {
const asset = AssetFactory.create();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing thumbhash', async () => {
@ -170,7 +170,7 @@ describe(MediaService.name, () => {
.files([AssetFileType.Thumbnail, AssetFileType.Preview])
.build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
@ -178,14 +178,14 @@ describe(MediaService.name, () => {
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing fullsize when feature is enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
const asset = { id: factory.uuid(), isEdited: false };
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: true });
@ -196,26 +196,26 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should not queue assets with missing fullsize when feature is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
const asset = { id: factory.uuid(), isEdited: false };
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue assets with edits but missing edited thumbnails', async () => {
const asset = AssetFactory.from().edit().build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
@ -226,27 +226,27 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should not queue assets with missing edited fullsize when feature is disabled', async () => {
const asset = AssetFactory.from().edit().build();
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue assets with missing fullsize when force is true, regardless of setting', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
const asset = { id: factory.uuid(), isEdited: false };
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false });
@ -257,13 +257,13 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalled();
expect(mocks.personUser.getAll).toHaveBeenCalled();
});
it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => {
const asset = AssetFactory.from().edit().build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false });
@ -278,24 +278,26 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith(undefined);
expect(mocks.personUser.getAll).toHaveBeenCalledWith(undefined);
});
});
describe('handleQueueMigration', () => {
it('should remove empty directories and queue jobs', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
const personUser = PersonUserFactory.create();
mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset]));
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
mocks.person.getAll.mockReturnValue(makeStream([person]));
mocks.personUser.getAll.mockReturnValue(makeStream([personUser]));
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success);
expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2);
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.PersonFileMigration, data: { personId: personUser.personId, ownerId: personUser.ownerId } },
]);
});
});
@ -1509,46 +1511,36 @@ describe(MediaService.name, () => {
describe('handleGeneratePersonThumbnail', () => {
it('should generate a thumbnail even if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
mocks.media.decodeImage.mockResolvedValue({
data: Buffer.from(''),
info: { width: 1000, height: 1000 } as OutputInfo,
});
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleGeneratePersonThumbnail({ personId: 'person-1', ownerId: 'user-1' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
it('should skip a person not found', async () => {
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person without a face asset id', async () => {
const person = PersonFactory.create({ faceAssetId: null });
mocks.person.getById.mockResolvedValue(person);
await sut.handleGeneratePersonThumbnail({ id: person.id });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person with face not found', async () => {
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
await sut.handleGeneratePersonThumbnail({ personId: 'person-1', ownerId: 'user-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should generate a thumbnail', async () => {
const person = PersonFactory.create();
const { personId, ownerId } = PersonUserFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
expect(mocks.personUser.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({ personId, ownerId });
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
colorspace: Colorspace.P3,
@ -1579,21 +1571,21 @@ describe(MediaService.name, () => {
},
expect.any(String),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
expect(mocks.personUser.update).toHaveBeenCalledWith({ personId, ownerId, thumbnailPath: expect.any(String) });
});
it('should use preview path if video', async () => {
const person = PersonFactory.create();
const { personId, ownerId } = PersonUserFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
expect(mocks.personUser.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({ personId, ownerId });
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
colorspace: Colorspace.P3,
@ -1624,19 +1616,19 @@ describe(MediaService.name, () => {
},
expect.any(String),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
expect(mocks.personUser.update).toHaveBeenCalledWith({ personId, ownerId, thumbnailPath: expect.any(String) });
});
it('should generate a thumbnail without going negative', async () => {
const person = PersonFactory.create();
const { personId, ownerId } = PersonUserFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
colorspace: Colorspace.P3,
@ -1670,16 +1662,18 @@ describe(MediaService.name, () => {
});
it('should generate a thumbnail without overflowing', async () => {
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
mocks.person.update.mockResolvedValue(person);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
mocks.personUser.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(
sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }),
).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
colorspace: Colorspace.P3,
@ -1713,16 +1707,18 @@ describe(MediaService.name, () => {
});
it('should handle negative coordinates', async () => {
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate);
mocks.person.update.mockResolvedValue(person);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate);
mocks.personUser.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 4624, height: 3080 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(
sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }),
).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
colorspace: Colorspace.P3,
@ -1756,16 +1752,18 @@ describe(MediaService.name, () => {
});
it('should handle overflowing coordinate', async () => {
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate);
mocks.person.update.mockResolvedValue(person);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate);
mocks.personUser.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 4624, height: 3080 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(
sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }),
).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
colorspace: Colorspace.P3,
@ -1799,11 +1797,11 @@ describe(MediaService.name, () => {
});
it('should use embedded preview if enabled and raw image', async () => {
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.person.update.mockResolvedValue(person);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.personUser.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const extracted = Buffer.from('');
const data = Buffer.from('');
@ -1812,7 +1810,9 @@ describe(MediaService.name, () => {
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(
sut.handleGeneratePersonThumbnail({ personId: person.personId, ownerId: person.ownerId }),
).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
@ -1847,31 +1847,31 @@ describe(MediaService.name, () => {
});
it('should not use embedded preview if enabled and not raw image', async () => {
const person = PersonFactory.create();
const { personId, ownerId } = PersonUserFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).not.toHaveBeenCalled();
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
it('should not use embedded preview if enabled and raw image if not exists', async () => {
const person = PersonFactory.create();
const { personId, ownerId } = PersonUserFactory.create();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
@ -1883,10 +1883,10 @@ describe(MediaService.name, () => {
});
it('should not use embedded preview if enabled and raw image if low resolution', async () => {
const person = PersonFactory.create();
const { personId, ownerId } = PersonUserFactory.create();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.personUser.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
const extracted = Buffer.from('');
const data = Buffer.from('');
@ -1895,7 +1895,7 @@ describe(MediaService.name, () => {
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleGeneratePersonThumbnail({ personId, ownerId })).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
@ -1911,7 +1911,7 @@ describe(MediaService.name, () => {
it('should queue all video assets', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueVideoConversion({ force: true });

View file

@ -96,19 +96,26 @@ export class MediaService extends BaseService {
await queueAll();
const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
const people = this.personUserRepository.getAll(force ? undefined : { thumbnailPath: '' });
for await (const person of people) {
if (!person.faceAssetId) {
const face = await this.personRepository.getRandomFace(person.id);
if (!person.thumbnailFaceAssetId) {
const face = await this.personRepository.getRandomFace(person.personId);
if (!face) {
continue;
}
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
await this.personUserRepository.update({
personId: person.personId,
ownerId: person.ownerId,
thumbnailFaceAssetId: face.id,
});
}
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } });
jobs.push({
name: JobName.PersonGenerateThumbnail,
data: { personId: person.personId, ownerId: person.ownerId },
});
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await queueAll();
}
@ -140,8 +147,8 @@ export class MediaService extends BaseService {
await this.jobRepository.queueAll(jobs);
jobs = [];
for await (const person of this.personRepository.getAll()) {
jobs.push({ name: JobName.PersonFileMigration, data: { id: person.id } });
for await (const { personId, ownerId } of this.personUserRepository.getAll()) {
jobs.push({ name: JobName.PersonFileMigration, data: { personId, ownerId } });
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(jobs);
@ -409,19 +416,22 @@ export class MediaService extends BaseService {
}
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
async handleGeneratePersonThumbnail({
personId,
ownerId,
}: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
const { image } = await this.getConfig({ withCache: true });
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
const data = await this.personUserRepository.getDataForThumbnailGenerationJob({ personId, ownerId });
if (!data) {
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
this.logger.error(`Could not generate person thumbnail for ${personId}: missing data`);
return JobStatus.Failed;
}
const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data;
const { x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data;
let inputImage: string | Buffer;
if (data.type === AssetType.Video) {
if (!previewPath) {
this.logger.error(`Could not generate person thumbnail for video ${id}: missing preview path`);
this.logger.error(`Could not generate person thumbnail for video ${personId}: missing preview path`);
return JobStatus.Failed;
}
inputImage = previewPath;
@ -439,7 +449,7 @@ export class MediaService extends BaseService {
orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined,
});
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id: personId, ownerId });
this.storageCore.ensureFolders(thumbnailPath);
const thumbnailOptions: GenerateThumbnailOptions = {
@ -462,7 +472,7 @@ export class MediaService extends BaseService {
};
await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath);
await this.personRepository.update({ id, thumbnailPath });
await this.personUserRepository.update({ personId, ownerId, thumbnailPath });
return JobStatus.Success;
}
@ -856,7 +866,7 @@ export class MediaService extends BaseService {
: undefined;
const originalDimensions = getDimensions(asset.exifInfo!);
const assetFaces = await this.personRepository.getFaces(asset.id, {});
const assetFaces = await this.personRepository.getFaces(asset.id);
const ocrData = await this.ocrRepository.getByAssetId(asset.id, {});
const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox);

View file

@ -1434,13 +1434,13 @@ describe(MetadataService.name, () => {
],
[],
);
expect(mocks.person.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
expect(mocks.personUser.updateAll).toHaveBeenCalledWith([
{ personId: 'random-uuid', ownerId: asset.ownerId, thumbnailFaceAssetId: 'random-uuid' },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: person.id },
data: { personId: 'random-uuid', ownerId: asset.ownerId },
},
]);
});
@ -1476,7 +1476,7 @@ describe(MetadataService.name, () => {
],
[],
);
expect(mocks.person.updateAll).not.toHaveBeenCalled();
expect(mocks.personUser.updateAll).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
});
@ -1565,13 +1565,13 @@ describe(MetadataService.name, () => {
],
[],
);
expect(mocks.person.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
expect(mocks.personUser.updateAll).toHaveBeenCalledWith([
{ personId: 'random-uuid', ownerId: asset.ownerId, thumbnailFaceAssetId: 'random-uuid' },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: person.id },
data: { personId: 'random-uuid', ownerId: asset.ownerId },
},
]);
},

View file

@ -28,7 +28,7 @@ import { ReverseGeocodeResult } from 'src/repositories/map.repository';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PersonUserTable } from 'src/schema/tables/person-user.table';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util';
@ -908,7 +908,13 @@ export class MetadataService extends BaseService {
}
private async applyTaggedFaces(
asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string },
asset: {
id: string;
ownerId: string;
ownerTrustedGroupId: string;
faces: { id: string; sourceType: SourceType }[];
originalPath: string;
},
tags: ImmichTags,
) {
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
@ -918,8 +924,8 @@ export class MetadataService extends BaseService {
const facesToAdd: (Insertable<AssetFaceTable> & { assetId: string })[] = [];
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
const missing: (Insertable<PersonTable> & { ownerId: string })[] = [];
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
const missing: (Insertable<PersonUserTable> & { name: string; trustedGroupId: string })[] = [];
const missingWithFaceAsset: { personId: string; ownerId: string; thumbnailFaceAssetId: string }[] = [];
const adjustedRegionInfo = this.orientRegionInfo(tags.RegionInfo, tags.Orientation);
const imageWidth = adjustedRegionInfo.AppliedToDimensions.W;
@ -953,16 +959,20 @@ export class MetadataService extends BaseService {
facesToAdd.push(face);
if (!existingNameMap.has(loweredName)) {
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id });
missing.push({
personId,
ownerId: asset.ownerId,
trustedGroupId: asset.ownerTrustedGroupId,
name: region.Name,
});
missingWithFaceAsset.push({ personId, ownerId: asset.ownerId, thumbnailFaceAssetId: face.id });
}
}
if (missing.length > 0) {
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
const newPersonIds = await this.personRepository.createAll(missing);
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
await this.jobRepository.queueAll(jobs);
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.personId}`)}`);
await this.personRepository.createAll(missing);
await this.personUserRepository.createAll(missing);
}
const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id);
@ -981,7 +991,11 @@ export class MetadataService extends BaseService {
}
if (missingWithFaceAsset.length > 0) {
await this.personRepository.updateAll(missingWithFaceAsset);
await this.personUserRepository.updateAll(missingWithFaceAsset);
const jobs = missing.map(
({ personId, ownerId }) => ({ name: JobName.PersonGenerateThumbnail, data: { personId, ownerId } }) as const,
);
await this.jobRepository.queueAll(jobs);
}
}

View file

@ -8,6 +8,7 @@ import { ImmichFileResponse } from 'src/utils/file';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { PersonUserFactory } from 'test/factories/person-user.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
@ -18,6 +19,7 @@ import {
getForAssetFace,
getForDetectedFaces,
getForFacialRecognitionJob,
getForPerson,
} from 'test/mappers';
import { newDate, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -37,13 +39,16 @@ describe(PersonService.name, () => {
describe('getAll', () => {
it('should get all hidden and visible people with thumbnails', async () => {
const auth = AuthFactory.create();
const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })];
const [person, hiddenPerson] = [
PersonFactory.create(),
PersonFactory.from().personUser({ isHidden: true }).build(),
];
mocks.person.getAllForUser.mockResolvedValue({
items: [person, hiddenPerson],
items: [getForPerson(person), getForPerson(hiddenPerson)],
hasNextPage: false,
});
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
mocks.personUser.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(auth, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
hasNextPage: false,
total: 2,
@ -63,13 +68,16 @@ describe(PersonService.name, () => {
it('should get all visible people and favorites should be first in the array', async () => {
const auth = AuthFactory.create();
const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()];
const [isFavorite, person] = [
PersonFactory.from().personUser({ isFavorite: true }).build(),
PersonFactory.create(),
];
mocks.person.getAllForUser.mockResolvedValue({
items: [isFavorite, person],
items: [getForPerson(isFavorite), getForPerson(person)],
hasNextPage: false,
});
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
mocks.personUser.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(auth, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
hasNextPage: false,
total: 2,
@ -108,10 +116,10 @@ describe(PersonService.name, () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser });
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
expect(mocks.person.getById).toHaveBeenCalledWith(person.id);
expect(mocks.person.getForOwner).toHaveBeenCalledWith(person.id, auth.user.id);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
});
@ -138,29 +146,29 @@ describe(PersonService.name, () => {
it('should throw an error when person has no thumbnail', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ thumbnailPath: '' });
const person = PersonUserFactory.create({ thumbnailPath: '' });
mocks.person.getById.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException);
mocks.personUser.get.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personId]));
await expect(sut.getThumbnail(auth, person.personId)).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personId]));
});
it('should serve the thumbnail', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.person.getById.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual(
mocks.personUser.get.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personId]));
await expect(sut.getThumbnail(auth, person.personId)).resolves.toEqual(
new ImmichFileResponse({
path: person.thumbnailPath,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithoutCache,
}),
);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personId]));
});
});
@ -204,13 +212,14 @@ describe(PersonService.name, () => {
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
mocks.person.update.mockResolvedValue(person);
mocks.personUser.update.mockResolvedValue(person.personUser);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({
id: person.id,
name: person.name,
birthDate: '1976-06-30',
thumbnailPath: person.thumbnailPath,
thumbnailPath: person.personUser.thumbnailPath,
isHidden: false,
isFavorite: false,
updatedAt: expect.any(String),
@ -223,40 +232,51 @@ describe(PersonService.name, () => {
it('should update a person visibility', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ isHidden: true });
const person = PersonFactory.from().personUser({ ownerId: auth.user.id, isHidden: true }).build();
mocks.person.update.mockResolvedValue(person);
mocks.personUser.update.mockResolvedValue(person.personUser);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual(
expect.objectContaining({ isHidden: true }),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true });
expect(mocks.personUser.update).toHaveBeenCalledWith({
personId: person.id,
ownerId: person.personUser.ownerId,
isHidden: true,
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should update a person favorite status', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create({ isFavorite: true });
const person = PersonFactory.from().personUser({ ownerId: auth.user.id, isFavorite: true }).build();
mocks.person.update.mockResolvedValue(person);
mocks.personUser.update.mockResolvedValue(person.personUser);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual(
expect.objectContaining({ isFavorite: true }),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true });
expect(mocks.personUser.update).toHaveBeenCalledWith({
personId: person.id,
ownerId: person.personUser.ownerId,
isFavorite: true,
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it("should update a person's thumbnailPath", async () => {
const face = AssetFaceFactory.create();
const auth = AuthFactory.create();
const person = PersonFactory.create();
const person = PersonFactory.from().personUser({ ownerId: auth.user.id }).build();
mocks.person.update.mockResolvedValue(person);
mocks.personUser.update.mockResolvedValue(person.personUser);
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
@ -265,14 +285,18 @@ describe(PersonService.name, () => {
expect.objectContaining({ id: person.id }),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id });
expect(mocks.personUser.update).toHaveBeenCalledWith({
personId: person.id,
ownerId: person.personUser.ownerId,
thumbnailFaceAssetId: face.id,
});
expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({
assetId: face.assetId,
personId: person.id,
});
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.PersonGenerateThumbnail,
data: { id: person.id },
data: { personId: person.id, ownerId: person.personUser.ownerId },
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
@ -318,10 +342,10 @@ describe(PersonService.name, () => {
it('should reassign a face', async () => {
const face = AssetFaceFactory.create();
const auth = AuthFactory.create();
const person = PersonFactory.create();
const person = PersonFactory.from().personUser({ thumbnailFaceAssetId: null }).build();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.person.getById.mockResolvedValue(person);
mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser });
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]);
mocks.person.reassignFace.mockResolvedValue(1);
@ -339,7 +363,7 @@ describe(PersonService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: person.id },
data: { personId: person.id, ownerId: auth.user.id },
},
]);
});
@ -347,7 +371,7 @@ describe(PersonService.name, () => {
describe('handlePersonMigration', () => {
it('should not move person files', async () => {
await expect(sut.handlePersonMigration(PersonFactory.create())).resolves.toBe(JobStatus.Failed);
await expect(sut.handlePersonMigration(PersonUserFactory.create())).resolves.toBe(JobStatus.Failed);
});
});
@ -378,7 +402,7 @@ describe(PersonService.name, () => {
it('should create a manual face and initialize the person feature photo creation', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
const person = PersonFactory.from().personUser({ thumbnailFaceAssetId: null }).build();
const featureFace = AssetFaceFactory.create({
assetId: asset.id,
personId: person.id,
@ -388,9 +412,9 @@ describe(PersonService.name, () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getById.mockResolvedValue(person);
mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser });
mocks.person.getRandomFace.mockResolvedValue(featureFace);
mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id });
mocks.personUser.update.mockResolvedValue(person.personUser);
await expect(
sut.createFace(auth, {
@ -418,21 +442,25 @@ describe(PersonService.name, () => {
sourceType: SourceType.Manual,
});
expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id });
expect(mocks.personUser.update).toHaveBeenCalledWith({
personId: person.id,
ownerId: person.personUser.ownerId,
thumbnailFaceAssetId: featureFace.id,
});
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.PersonGenerateThumbnail, data: { id: person.id } },
{ name: JobName.PersonGenerateThumbnail, data: { personId: person.id, ownerId: person.personUser.ownerId } },
]);
});
it('should not update the person feature photo if one already exists', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: newUuid() });
const person = PersonFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getById.mockResolvedValue(person);
mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser });
await expect(
sut.createFace(auth, {
@ -449,14 +477,14 @@ describe(PersonService.name, () => {
expect(mocks.person.createAssetFace).toHaveBeenCalledOnce();
expect(mocks.person.getRandomFace).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.personUser.update).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
it('should reject creating a face on an asset the user does not own', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
const person = PersonFactory.from().personUser({ thumbnailFaceAssetId: null }).build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
@ -483,11 +511,11 @@ describe(PersonService.name, () => {
const person = PersonFactory.create();
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
await sut.createNewFeaturePhoto([person.id]);
await sut.createNewFeaturePhoto([person.id], person.personUser.ownerId);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: person.id },
data: { personId: person.id, ownerId: person.personUser.ownerId },
},
]);
});
@ -502,14 +530,14 @@ describe(PersonService.name, () => {
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getById.mockResolvedValue(person);
mocks.person.getForOwner.mockResolvedValue({ ...person, ...person.personUser });
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
birthDate: person.birthDate,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
isHidden: person.personUser.isHidden,
isFavorite: person.personUser.isFavorite,
id: person.id,
name: person.name,
thumbnailPath: person.thumbnailPath,
thumbnailPath: person.personUser.thumbnailPath,
updatedAt: expect.any(String),
});
@ -539,23 +567,25 @@ describe(PersonService.name, () => {
describe('createPerson', () => {
it('should create a new person', async () => {
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.create.mockResolvedValue(PersonFactory.create());
mocks.person.create.mockResolvedValue(person);
await expect(sut.create(auth, {})).resolves.toBeDefined();
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id });
expect(mocks.person.create).toHaveBeenCalledWith({ trustedGroupId: auth.user.trustedGroupId });
expect(mocks.personUser.create).toHaveBeenCalledWith({ personId: person.id, ownerId: auth.user.id });
});
});
describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => {
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]);
await sut.handlePersonCleanup();
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
});
});
@ -588,15 +618,15 @@ describe(PersonService.name, () => {
it('should queue all assets', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]);
await sut.handleQueueDetectFaces({ force: true });
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
@ -631,12 +661,11 @@ describe(PersonService.name, () => {
it('should delete existing people and faces if forced', async () => {
const asset = AssetFactory.create();
const face = AssetFaceFactory.from().person().build();
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]);
mocks.person.deleteFaces.mockResolvedValue();
await sut.handleQueueDetectFaces({ force: true });
@ -648,7 +677,7 @@ describe(PersonService.name, () => {
data: { id: asset.id },
},
]);
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
});
@ -698,7 +727,7 @@ describe(PersonService.name, () => {
delayed: 0,
});
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
mocks.person.getAllWithoutFaces.mockResolvedValue([]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({});
@ -728,9 +757,8 @@ describe(PersonService.name, () => {
failed: 0,
delayed: 0,
});
mocks.person.getAll.mockReturnValue(makeStream());
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
mocks.person.getAllWithoutFaces.mockResolvedValue([]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true });
@ -759,9 +787,8 @@ describe(PersonService.name, () => {
failed: 0,
delayed: 0,
});
mocks.person.getAll.mockReturnValue(makeStream());
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
mocks.person.getAllWithoutFaces.mockResolvedValue([]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([]);
mocks.person.unassignFaces.mockResolvedValue();
await sut.handleQueueRecognizeFaces({ force: false, nightly: true });
@ -790,7 +817,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() });
mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString());
mocks.person.getAllFaces.mockReturnValue(makeStream([AssetFaceFactory.create()]));
mocks.person.getAllWithoutFaces.mockResolvedValue([]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
@ -804,7 +831,7 @@ describe(PersonService.name, () => {
it('should delete existing people if forced', async () => {
const face = AssetFaceFactory.from().person().build();
const person = PersonFactory.create();
const person = PersonUserFactory.create();
mocks.job.getJobCounts.mockResolvedValue({
active: 1,
@ -814,9 +841,8 @@ describe(PersonService.name, () => {
failed: 0,
delayed: 0,
});
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
mocks.personUser.getAllWithoutFaces.mockResolvedValue([person]);
mocks.person.unassignFaces.mockResolvedValue();
await sut.handleQueueRecognizeFaces({ force: true });
@ -829,7 +855,7 @@ describe(PersonService.name, () => {
data: { id: face.id, deferred: false },
},
]);
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.person.delete).toHaveBeenCalledWith([person.personId]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
});
@ -1028,6 +1054,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
mocks.user.get.mockResolvedValue(asset.owner);
mocks.person.create.mockResolvedValue(primaryFace.person!);
await sut.handleRecognizeFaces({ id: noPerson1.id });
@ -1061,6 +1088,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
mocks.user.get.mockResolvedValue(asset.owner);
mocks.person.create.mockResolvedValue(face.person!);
await sut.handleRecognizeFaces({ id: noPerson.id });
@ -1094,6 +1122,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset));
mocks.user.get.mockResolvedValue(asset.owner);
mocks.person.create.mockResolvedValue(face.person!);
await sut.handleRecognizeFaces({ id: noPerson.id });
@ -1123,13 +1152,18 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
mocks.user.get.mockResolvedValue(asset.owner);
mocks.person.create.mockResolvedValue(person);
await sut.handleRecognizeFaces({ id: noPerson1.id });
expect(mocks.person.create).toHaveBeenCalledWith({
trustedGroupId: asset.owner.trustedGroupId,
});
expect(mocks.personUser.create).toHaveBeenCalledWith({
personId: person.id,
ownerId: asset.ownerId,
faceAssetId: noPerson1.id,
thumbnailFaceAssetId: noPerson1.id,
});
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
faceIds: [noPerson1.id],
@ -1144,6 +1178,7 @@ describe(PersonService.name, () => {
mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset));
mocks.user.get.mockResolvedValue(asset.owner);
mocks.person.create.mockResolvedValue(PersonFactory.create());
await sut.handleRecognizeFaces({ id: face.id });
@ -1166,6 +1201,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
mocks.user.get.mockResolvedValue(asset.owner);
mocks.person.create.mockResolvedValue(PersonFactory.create());
await sut.handleRecognizeFaces({ id: noPerson1.id });
@ -1191,6 +1227,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset));
mocks.user.get.mockResolvedValue(asset.owner);
mocks.person.create.mockResolvedValue(PersonFactory.create());
await sut.handleRecognizeFaces({ id: noPerson1.id, deferred: true });
@ -1226,6 +1263,9 @@ describe(PersonService.name, () => {
mocks.person.getById.mockResolvedValueOnce(person);
mocks.person.getById.mockResolvedValueOnce(mergePerson);
mocks.personUser.getThumbnailsForPerson.mockResolvedValue([
{ thumbnailPath: mergePerson.personUser.thumbnailPath },
]);
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
@ -1251,6 +1291,9 @@ describe(PersonService.name, () => {
mocks.person.getById.mockResolvedValueOnce(person);
mocks.person.getById.mockResolvedValueOnce(mergePerson);
mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name });
mocks.personUser.getThumbnailsForPerson.mockResolvedValue([
{ thumbnailPath: mergePerson.personUser.thumbnailPath },
]);
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
@ -1344,8 +1387,10 @@ describe(PersonService.name, () => {
it('should map a face', () => {
const user = UserFactory.create();
const auth = AuthFactory.create({ id: user.id });
const person = PersonFactory.create({ ownerId: user.id });
const face = AssetFaceFactory.from().person(person).build();
const person = PersonFactory.from().personUser({ ownerId: user.id }).build();
const face = AssetFaceFactory.from()
.person(person, (builder) => builder.personUser(person.personUser))
.build();
expect(mapFaces(getForAssetFace(face), auth)).toEqual({
boundingBoxX1: 100,
@ -1356,7 +1401,7 @@ describe(PersonService.name, () => {
imageHeight: 500,
imageWidth: 400,
sourceType: SourceType.MachineLearning,
person: mapPerson(person),
person: mapPerson(getForPerson(person)),
});
});

View file

@ -1,7 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Insertable, Updateable } from 'kysely';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { Person } from 'src/database';
import { Chunked, OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@ -38,6 +37,7 @@ import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { UpdateFacesData } from 'src/repositories/person.repository';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
@ -57,17 +57,17 @@ export class PersonService extends BaseService {
};
if (closestPersonId) {
const person = await this.personRepository.getById(closestPersonId);
if (!person?.faceAssetId) {
const person = await this.personUserRepository.get({ personId: closestPersonId, ownerId: auth.user.id });
if (!person?.thumbnailFaceAssetId) {
throw new NotFoundException('Person not found');
}
closestFaceAssetId = person.faceAssetId;
closestFaceAssetId = person.thumbnailFaceAssetId;
}
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
withHidden,
closestFaceAssetId,
});
const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id);
const { total, hidden } = await this.personUserRepository.getNumberOfPeople(auth.user.id);
return {
people: items.map((person) => mapPerson(person)),
@ -79,7 +79,7 @@ export class PersonService extends BaseService {
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
const person = await this.findOrFail(personId);
const person = await this.findOrFail(personId, auth.user.id);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
for (const data of dto.data) {
@ -87,10 +87,10 @@ export class PersonService extends BaseService {
for (const face of faces) {
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] });
if (person.faceAssetId === null) {
changeFeaturePhoto.push(person.id);
if (person.thumbnailFaceAssetId === null) {
changeFeaturePhoto.push(person.personId);
}
if (face.person && face.person.faceAssetId === face.id) {
if (face.person && face.person.thumbnailFaceAssetId === face.id) {
changeFeaturePhoto.push(face.person.id);
}
@ -101,7 +101,7 @@ export class PersonService extends BaseService {
}
if (changeFeaturePhoto.length > 0) {
// Remove duplicates
await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]);
await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)], auth.user.id);
}
return result;
}
@ -109,30 +109,30 @@ export class PersonService extends BaseService {
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] });
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] });
const face = await this.personRepository.getFaceById(dto.id);
const person = await this.findOrFail(personId);
const face = await this.personRepository.getFaceById(dto.id, auth.user.id);
const person = await this.findOrFail(personId, auth.user.id);
await this.personRepository.reassignFace(face.id, personId);
if (person.faceAssetId === null) {
await this.createNewFeaturePhoto([person.id]);
if (person.thumbnailFaceAssetId === null) {
await this.createNewFeaturePhoto([person.id], auth.user.id);
}
if (face.person && face.person.faceAssetId === face.id) {
await this.createNewFeaturePhoto([face.person.id]);
if (face.person && face.person.thumbnailFaceAssetId === face.id) {
await this.createNewFeaturePhoto([face.person.id], auth.user.id);
}
return mapPerson(await this.findOrFail(personId));
return mapPerson(await this.findOrFail(personId, auth.user.id));
}
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id);
const faces = await this.personRepository.getFaces(dto.id, auth.user.id);
const asset = await this.assetRepository.getForFaces(dto.id);
const assetDimensions = getDimensions(asset);
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
}
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
async createNewFeaturePhoto(changeFeaturePhoto: string[], ownerId: string) {
this.logger.debug(
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
);
@ -142,8 +142,8 @@ export class PersonService extends BaseService {
const assetFace = await this.personRepository.getRandomFace(personId);
if (assetFace) {
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id });
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } });
await this.personUserRepository.update({ personId, ownerId, thumbnailFaceAssetId: assetFace.id });
jobs.push({ name: JobName.PersonGenerateThumbnail, data: { personId, ownerId } });
}
}
@ -152,7 +152,7 @@ export class PersonService extends BaseService {
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return mapPerson(await this.findOrFail(id));
return mapPerson(await this.findOrFail(id, auth.user.id));
}
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
@ -162,7 +162,7 @@ export class PersonService extends BaseService {
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
const person = await this.personRepository.getById(id);
const person = await this.personUserRepository.get({ personId: id, ownerId: auth.user.id });
if (!person || !person.thumbnailPath) {
throw new NotFoundException();
}
@ -176,15 +176,19 @@ export class PersonService extends BaseService {
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
const person = await this.personRepository.create({
ownerId: auth.user.id,
name: dto.name,
birthDate: dto.birthDate,
color: dto.color,
trustedGroupId: auth.user.trustedGroupId,
});
const personUser = await this.personUserRepository.create({
personId: person.id,
ownerId: auth.user.id,
isHidden: dto.isHidden,
isFavorite: dto.isFavorite,
color: dto.color,
});
return mapPerson(person);
return mapPerson({ ...person, ...personUser });
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
@ -205,19 +209,27 @@ export class PersonService extends BaseService {
const person = await this.personRepository.update({
id,
faceAssetId: faceId,
name,
birthDate,
isHidden,
isFavorite,
color,
});
const personUser = await this.personUserRepository.update({
personId: id,
ownerId: auth.user.id,
thumbnailFaceAssetId: faceId,
isHidden,
isFavorite,
});
if (assetId) {
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id } });
await this.jobRepository.queue({
name: JobName.PersonGenerateThumbnail,
data: { personId: personUser.personId, ownerId: personUser.ownerId },
});
}
return mapPerson(person);
return mapPerson({ ...person, ...personUser });
}
delete(auth: AuthDto, id: string): Promise<void> {
@ -246,20 +258,20 @@ export class PersonService extends BaseService {
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.PersonDelete, ids });
const people = await this.personRepository.getForPeopleDelete(ids);
const people = await this.personUserRepository.getForPeopleDelete(ids);
await this.removeAllPeople(people);
}
@Chunked()
private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) {
private async removeAllPeople(people: { personId: string; thumbnailPath: string }[]) {
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.delete(people.map((person) => person.id));
await this.personRepository.delete(people.map(({ personId }) => personId));
this.logger.debug(`Deleted ${people.length} people`);
}
@OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask })
async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.personRepository.getAllWithoutFaces();
const people = await this.personUserRepository.getAllWithoutFaces();
await this.removeAllPeople(people);
return JobStatus.Success;
}
@ -484,8 +496,14 @@ export class PersonService extends BaseService {
return JobStatus.Skipped;
}
const owner = await this.userRepository.get(face.asset.ownerId, {});
if (!owner) {
this.logger.warn('Owner of asset the face belongs to not found');
return JobStatus.Failed;
}
const matches = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
trustedGroupId: owner.trustedGroupId,
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces,
@ -512,7 +530,7 @@ export class PersonService extends BaseService {
let personId = matches.find((match) => match.personId)?.personId;
if (!personId) {
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
trustedGroupId: owner.trustedGroupId,
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 1,
@ -527,9 +545,14 @@ export class PersonService extends BaseService {
if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
const newPerson = await this.personRepository.create({ trustedGroupId: owner.trustedGroupId });
await this.personUserRepository.create({
personId: newPerson.id,
ownerId: face.asset.ownerId,
thumbnailFaceAssetId: face.id,
});
personId = newPerson.id;
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { personId, ownerId: owner.id } });
}
if (personId) {
@ -541,13 +564,16 @@ export class PersonService extends BaseService {
}
@OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration })
async handlePersonMigration({ id }: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
const person = await this.personRepository.getById(id);
async handlePersonMigration(data: JobOf<JobName.PersonFileMigration>): Promise<JobStatus> {
const person = await this.personUserRepository.get(data);
if (!person) {
return JobStatus.Failed;
}
await this.storageCore.movePersonFile(person, PersonPathType.Face);
await this.storageCore.movePersonFile(
{ id: person.personId, ownerId: person.ownerId, thumbnailPath: person.thumbnailPath },
PersonPathType.Face,
);
return JobStatus.Success;
}
@ -559,7 +585,10 @@ export class PersonService extends BaseService {
}
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] });
let primaryPerson = await this.findOrFail(id);
let primaryPerson = await this.personRepository.getById(id);
if (!primaryPerson) {
throw new BadRequestException('Person not found');
}
const primaryName = primaryPerson.name || primaryPerson.id;
const results: BulkIdResponseDto[] = [];
@ -584,7 +613,7 @@ export class PersonService extends BaseService {
continue;
}
const update: Updateable<Person> & { id: string } = { id: primaryPerson.id };
const update: Updateable<PersonTable> & { id: string } = { id: primaryPerson.id };
if (!primaryPerson.name && mergePerson.name) {
update.name = mergePerson.name;
}
@ -602,7 +631,10 @@ export class PersonService extends BaseService {
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
await this.removeAllPeople([mergePerson]);
const thumbnails = await this.personUserRepository.getThumbnailsForPerson(mergePerson.id);
await this.removeAllPeople(
thumbnails.map(({ thumbnailPath }) => ({ personId: mergePerson.id, thumbnailPath })),
);
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
results.push({ id: mergeId, success: true });
@ -614,8 +646,8 @@ export class PersonService extends BaseService {
return results;
}
private async findOrFail(id: string) {
const person = await this.personRepository.getById(id);
private async findOrFail(id: string, ownerId: string) {
const person = await this.personRepository.getForOwner(id, ownerId);
if (!person) {
throw new BadRequestException('Person not found');
}
@ -631,7 +663,7 @@ export class PersonService extends BaseService {
const [asset, person] = await Promise.all([
this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }),
this.findOrFail(dto.personId),
this.findOrFail(dto.personId, auth.user.id),
]);
if (!asset) {
@ -690,8 +722,8 @@ export class PersonService extends BaseService {
sourceType: SourceType.Manual,
});
if (!person.faceAssetId) {
await this.createNewFeaturePhoto([person.id]);
if (!person.thumbnailFaceAssetId) {
await this.createNewFeaturePhoto([person.id], person.ownerId);
}
}

View file

@ -242,6 +242,7 @@ export class StorageTemplateService extends BaseService {
oldPath,
newPath,
assetInfo: { sizeInBytes: fileSizeInByte, checksum },
ownerId: asset.ownerId,
});
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path;
@ -251,6 +252,7 @@ export class StorageTemplateService extends BaseService {
pathType: AssetFileType.Sidecar,
oldPath: sidecarPath,
newPath: `${newPath}.xmp`,
ownerId: asset.ownerId,
});
}
} catch (error: any) {

View file

@ -10,6 +10,7 @@ import {
syncAlbumV2ToV1,
SyncAssetV2,
SyncItem,
syncPersonV2ToV1,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { JobName, QueueName, SyncEntityType, SyncRequestType } from 'src/enum';
@ -72,7 +73,9 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.PartnerAssetExifsV1,
SyncRequestType.MemoriesV1,
SyncRequestType.MemoryToAssetsV1,
SyncRequestType.PeopleV2,
SyncRequestType.PeopleV1,
SyncRequestType.PersonUsersV1,
SyncRequestType.AssetFacesV1,
SyncRequestType.AssetFacesV2,
SyncRequestType.UserMetadataV1,
@ -187,6 +190,8 @@ export class SyncService extends BaseService {
[SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap),
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id),
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
[SyncRequestType.PeopleV2]: () => this.syncPeopleV2(options, response, checkpointMap),
[SyncRequestType.PersonUsersV1]: () => this.syncPersonUsersV1(options, response, checkpointMap),
[SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap),
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
[SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth),
@ -220,6 +225,7 @@ export class SyncService extends BaseService {
await this.syncRepository.memory.cleanupAuditTable(pruneThreshold);
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
await this.syncRepository.personUser.cleanupAuditTable(pruneThreshold);
await this.syncRepository.person.cleanupAuditTable(pruneThreshold);
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
@ -834,6 +840,37 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.PersonV1;
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
const personUser = await this.syncRepository.person.getPersonUser({ personId: data.id, ownerId: options.userId });
if (personUser) {
send(response, { type: upsertType, ids: [updateId], data: syncPersonV2ToV1(data, personUser) });
}
}
}
private async syncPeopleV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.PersonDeleteV1;
const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.PersonV2;
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncPersonUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.PersonUserDeleteV1;
const deletes = this.syncRepository.personUser.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.PersonUserV1;
const upserts = this.syncRepository.personUser.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}

View file

@ -349,6 +349,11 @@ export interface IIntegrityPathWithChecksumJob {
items: { path: string; reportId: string | null; checksum?: string | null }[];
}
export interface IPersonJob {
personId: string;
ownerId: string;
}
export interface JobCounts {
active: number;
completed: number;
@ -385,7 +390,7 @@ export type JobItem =
// Migration
| { name: JobName.FileMigrationQueueAll; data?: IBaseJob }
| { name: JobName.AssetFileMigration; data: IEntityJob }
| { name: JobName.PersonFileMigration; data: IEntityJob }
| { name: JobName.PersonFileMigration; data: IPersonJob }
// Metadata Extraction
| { name: JobName.AssetExtractMetadataQueueAll; data: IBaseJob }
@ -404,7 +409,7 @@ export type JobItem =
| { name: JobName.AssetDetectFaces; data: IEntityJob }
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
| { name: JobName.FacialRecognition; data: IDeferrableJob }
| { name: JobName.PersonGenerateThumbnail; data: IEntityJob }
| { name: JobName.PersonGenerateThumbnail; data: IPersonJob }
// Smart Search
| { name: JobName.SmartSearchQueueAll; data: IBaseJob }

View file

@ -247,7 +247,21 @@ export function withFacesAndPeople(
.selectFrom('asset_face')
.leftJoinLateral(
(eb) =>
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
eb
.selectFrom('person')
.selectAll('person')
.whereRef('asset_face.personId', '=', 'person.id')
.innerJoin('person_user', (join) =>
join.onRef('person_user.personId', '=', 'person.id').onRef('person_user.ownerId', '=', 'asset.ownerId'),
)
.select([
'person_user.ownerId',
'person_user.thumbnailFaceAssetId',
'person_user.thumbnailPath',
'person_user.isHidden',
'person_user.isFavorite',
])
.as('person'),
(join) => join.onTrue(),
)
.selectAll('asset_face')

View file

@ -3,7 +3,7 @@ import { SourceType } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { build } from 'test/factories/builder.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { AssetFaceLike, FactoryBuilder, PersonLike } from 'test/factories/types';
import { AssetFaceLike, FactoryBuilder, PersonLike, PersonUserLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFaceFactory {
@ -35,8 +35,11 @@ export class AssetFaceFactory {
});
}
person(dto: PersonLike = {}, builder?: FactoryBuilder<PersonFactory>) {
person(dto: PersonLike & { personUser?: PersonUserLike } = {}, builder?: FactoryBuilder<PersonFactory>) {
this.#person = build(PersonFactory.from(dto), builder);
if (dto.personUser && !builder) {
this.#person.personUser(dto.personUser);
}
this.value.personId = this.#person.build().id;
return this;
}

View file

@ -40,7 +40,7 @@ export class AuthFactory {
}
build(): AuthDto {
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes, trustedGroupId } = this.#user.build();
return {
user: {
@ -50,6 +50,7 @@ export class AuthFactory {
email,
quotaUsageInBytes,
quotaSizeInBytes,
trustedGroupId,
},
sharedLink: this.#sharedLink?.build(),
apiKey: this.#apiKey?.build(),

View file

@ -0,0 +1,47 @@
import { Selectable } from 'kysely';
import { PersonUserTable } from 'src/schema/tables/person-user.table';
import { build } from 'test/factories/builder.factory';
import { FactoryBuilder, PersonUserLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class PersonUserFactory {
#owner: UserFactory;
private constructor(private readonly value: Selectable<PersonUserTable>) {
this.#owner = UserFactory.from({ id: value.ownerId });
}
static create(dto: PersonUserLike = {}) {
return PersonUserFactory.from(dto).build();
}
static from(dto: PersonUserLike = {}) {
return new PersonUserFactory({
createdAt: newDate(),
personId: newUuid(),
ownerId: newUuid(),
isFavorite: false,
isHidden: false,
updatedAt: newDate(),
updateId: newUuidV7(),
thumbnailFaceAssetId: newUuid(),
thumbnailPath: '/data/thumbs/person-thumbnail.jpg',
...dto,
});
}
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
const user = build(UserFactory.from(dto), builder);
this.value.ownerId = user.build().id;
this.#owner = user;
return this;
}
build() {
return {
...this.value,
user: this.#owner.build(),
};
}
}

View file

@ -1,9 +1,13 @@
import { Selectable } from 'kysely';
import { PersonTable } from 'src/schema/tables/person.table';
import { PersonLike } from 'test/factories/types';
import { build } from 'test/factories/builder.factory';
import { PersonUserFactory } from 'test/factories/person-user.factory';
import { FactoryBuilder, PersonLike, PersonUserLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class PersonFactory {
#personUser!: PersonUserFactory;
private constructor(private readonly value: Selectable<PersonTable>) {}
static create(dto: PersonLike = {}) {
@ -15,20 +19,21 @@ export class PersonFactory {
birthDate: null,
color: null,
createdAt: newDate(),
faceAssetId: null,
id: newUuid(),
isFavorite: false,
isHidden: false,
name: 'person',
ownerId: newUuid(),
thumbnailPath: '/data/thumbs/person-thumbnail.jpg',
updatedAt: newDate(),
updateId: newUuidV7(),
trustedGroupId: newUuid(),
...dto,
});
}).personUser();
}
personUser(dto: PersonUserLike = {}, builder?: FactoryBuilder<PersonUserFactory>) {
this.#personUser = build(PersonUserFactory.from({ ...dto, personId: this.value.id }), builder);
return this;
}
build() {
return { ...this.value };
return { ...this.value, personUser: this.#personUser.build() };
}
}

View file

@ -11,6 +11,7 @@ import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonUserTable } from 'src/schema/tables/person-user.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
@ -29,6 +30,7 @@ export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type PersonUserLike = Partial<Selectable<PersonUserTable>>;
export type StackLike = Partial<Selectable<StackTable>>;
export type MemoryLike = Partial<Selectable<MemoryTable>>;
export type PartnerLike = Partial<Selectable<PartnerTable>>;

View file

@ -35,6 +35,7 @@ export class UserFactory {
status: UserStatus.Active,
profileChangedAt: newDate(),
updateId: newUuidV7(),
trustedGroupId: newUuid(),
...dto,
});
}

View file

@ -9,6 +9,7 @@ const authUser = {
isAdmin: true,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
trustedGroupId: 'admin_trusted_group_id',
},
user1: {
id: 'user-id',
@ -17,6 +18,7 @@ const authUser = {
isAdmin: false,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
trustedGroupId: 'user1_trusted_group_id',
},
};
@ -36,6 +38,7 @@ export const authStub = {
isAdmin: false,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
trustedGroupId: 'user2_trusted_group_id',
},
session: {
id: 'token-id',

View file

@ -9,6 +9,7 @@ import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { MemoryFactory } from 'test/factories/memory.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { StackFactory } from 'test/factories/stack.factory';
import { UserFactory } from 'test/factories/user.factory';
@ -97,7 +98,7 @@ export const getForAsset = (asset: ReturnType<AssetFactory['build']>) => {
...asset,
faces: asset.faces.map((face) => ({
...getDehydrated(face),
person: face.person ? getDehydrated(face.person) : null,
person: face.person ? getDehydrated(getForPerson(face.person)) : null,
})),
owner: getDehydrated(asset.owner),
stack: asset.stack
@ -109,6 +110,15 @@ export const getForAsset = (asset: ReturnType<AssetFactory['build']>) => {
};
};
export const getForPerson = (person: ReturnType<PersonFactory['build']>) => ({
...person,
ownerId: person.personUser.ownerId,
isFavorite: person.personUser.isFavorite,
isHidden: person.personUser.isHidden,
thumbnailFaceAssetId: person.personUser.thumbnailFaceAssetId,
thumbnailPath: person.personUser.thumbnailPath,
});
export const getForPartner = (
partner: Selectable<PartnerTable> & Record<'sharedWith' | 'sharedBy', ReturnType<UserFactory['build']>>,
) => ({
@ -136,6 +146,7 @@ export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']
originalFileName: asset.originalFileName,
originalPath: asset.originalPath,
ownerId: asset.ownerId,
ownerTrustedGroupId: asset.owner.trustedGroupId,
type: asset.type,
isEdited: asset.isEdited,
width: asset.width,
@ -162,7 +173,7 @@ export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>
export const getForAssetFace = (face: ReturnType<AssetFaceFactory['build']>) => ({
...face,
person: face.person ? getDehydrated(face.person) : null,
person: face.person ? getDehydrated(getForPerson(face.person)) : null,
});
export const getForDetectedFaces = (asset: ReturnType<AssetFactory['build']>) => ({

View file

@ -42,6 +42,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { SearchRepository } from 'src/repositories/search.repository';
@ -67,6 +68,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { PersonUserTable } from 'src/schema/tables/person-user.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { StackTable } from 'src/schema/tables/stack.table';
@ -262,12 +264,18 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
return { jobStatus, result };
}
async newPerson(dto: Partial<Insertable<PersonTable>> & { ownerId: string }) {
async newPerson(dto: Partial<Insertable<PersonTable>> & { trustedGroupId: string }) {
const person = mediumFactory.personInsert(dto);
const result = await this.get(PersonRepository).create(person);
return { person, result };
}
async newPersonUser(dto: Partial<Insertable<PersonUserTable>> & { personId: string; ownerId: string }) {
const personUser = mediumFactory.personUserInsert(dto);
const result = await this.get(PersonUserRepository).create(personUser);
return { personUser, result };
}
async newSession(dto: Partial<Insertable<SessionTable>> & { userId: string }) {
const session = mediumFactory.sessionInsert(dto);
const result = await this.get(SessionRepository).create(session);
@ -281,6 +289,7 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
session,
user: {
id: user.id,
trustedGroupId: user.trustedGroupId,
name: user.name,
email: user.email,
},
@ -451,6 +460,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
case OcrRepository:
case PartnerRepository:
case PersonRepository:
case PersonUserRepository:
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
@ -519,6 +529,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case OcrRepository:
case PartnerRepository:
case PersonRepository:
case PersonUserRepository:
case SessionRepository:
case SyncRepository:
case SyncCheckpointRepository:
@ -675,18 +686,13 @@ const assetJobStatusInsert = (
};
};
const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: string }) => {
const personInsert = (person: Partial<Insertable<PersonTable>> & { trustedGroupId: string }) => {
const defaults = {
birthDate: person.birthDate || null,
color: person.color || null,
createdAt: person.createdAt || newDate(),
faceAssetId: person.faceAssetId || null,
id: person.id || newUuid(),
isFavorite: person.isFavorite || false,
isHidden: person.isHidden || false,
name: person.name || 'Test Name',
ownerId: person.ownerId || newUuid(),
thumbnailPath: person.thumbnailPath || '/path/to/thumbnail.jpg',
};
return {
...defaults,
@ -694,6 +700,20 @@ const personInsert = (person: Partial<Insertable<PersonTable>> & { ownerId: stri
};
};
const personUserInsert = (personUser: Partial<Insertable<PersonUserTable>>) => {
const defaults = {
ownerId: newUuid(),
personId: newUuid(),
createdAt: newDate(),
isFavorite: false,
isHidden: false,
thumbnailFaceAssetId: null,
thumbnailPath: '/path/to/thumbnail.jpg',
} satisfies Insertable<PersonUserTable>;
return { ...defaults, ...personUser };
};
const sha256 = (value: string) => createHash('sha256').update(value).digest();
const sessionInsert = ({
@ -732,6 +752,7 @@ const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
avatarColor: null,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
trustedGroupId: newUuid(),
};
return { ...defaults, ...user, id };
@ -835,6 +856,7 @@ export const mediumFactory = {
albumInsert,
faceInsert,
personInsert,
personUserInsert,
sessionInsert,
syncStream,
userInsert,

View file

@ -1,7 +1,7 @@
import { Kysely } from 'kysely';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
@ -15,22 +15,21 @@ const setup = (db?: Kysely<DB>) => {
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(PersonRepository) };
return { ctx, sut: ctx.get(PersonUserRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(PersonRepository.name, () => {
describe(PersonUserRepository.name, () => {
describe('getDataForThumbnailGenerationJob', () => {
it('should not return the edited preview path', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
const { assetFace } = await ctx.newAssetFace({
assetId: asset.id,
personId: person.id,
@ -40,8 +39,7 @@ describe(PersonRepository.name, () => {
boundingBoxY2: 90,
});
// there's a circular dependency between assetFace and person, so we need to update the person after creating the assetFace
await ctx.database.updateTable('person').set({ faceAssetId: assetFace.id }).where('id', '=', person.id).execute();
await ctx.newPersonUser({ ownerId: user.id, personId: person.id, thumbnailFaceAssetId: assetFace.id });
await ctx.newAssetFile({
assetId: asset.id,
@ -56,7 +54,7 @@ describe(PersonRepository.name, () => {
isEdited: false,
});
const result = await sut.getDataForThumbnailGenerationJob(person.id);
const result = await sut.getDataForThumbnailGenerationJob({ personId: person.id, ownerId: user.id });
expect(result).toEqual(
expect.objectContaining({

View file

@ -7,6 +7,7 @@ import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
@ -20,7 +21,14 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository],
real: [
AccessRepository,
DatabaseRepository,
PersonUserRepository,
PersonRepository,
AssetRepository,
AssetEditRepository,
],
mock: [JobRepository, LoggingRepository, StorageRepository],
});
};
@ -43,7 +51,8 @@ describe(PersonService.name, () => {
const personRepo = ctx.get(PersonRepository);
const storageMock = ctx.getMock(StorageRepository);
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
const { personUser } = await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
@ -51,7 +60,7 @@ describe(PersonService.name, () => {
await expect(sut.delete(auth, person.id)).resolves.toBeUndefined();
await expect(personRepo.getById(person.id)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith(personUser.thumbnailPath);
});
});
@ -68,8 +77,10 @@ describe(PersonService.name, () => {
const storageMock = ctx.getMock(StorageRepository);
const personRepo = ctx.get(PersonRepository);
const { user } = await ctx.newUser();
const { person: person1 } = await ctx.newPerson({ ownerId: user.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user.id });
const { person: person1 } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
const { personUser: personUser1 } = await ctx.newPersonUser({ personId: person1.id, ownerId: user.id });
const { person: person2 } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
const { personUser: personUser2 } = await ctx.newPersonUser({ personId: person2.id, ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
@ -78,8 +89,8 @@ describe(PersonService.name, () => {
await expect(personRepo.getById(person2.id)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith(personUser1.thumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith(personUser2.thumbnailPath);
});
});
@ -87,7 +98,8 @@ describe(PersonService.name, () => {
it('should store and retrieve the face as-is when there are no edits', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -127,7 +139,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates when the asset is edited (Crop)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -199,7 +212,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates when the asset is edited (Rotate 90)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageWidth: 200, exifImageHeight: 100 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -264,7 +278,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates when the asset is edited (Mirror Horizontal)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -329,7 +344,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates when the asset is edited (Crop + Rotate)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -403,7 +419,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates when the asset is edited (Crop + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -477,7 +494,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates when the asset is edited (Rotate + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -548,7 +566,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates when the asset is edited (Crop + Rotate + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -628,7 +647,8 @@ describe(PersonService.name, () => {
it('should properly transform the coordinates with multiple mirrors in sequence', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@ -699,7 +719,8 @@ describe(PersonService.name, () => {
it('should properly handle exif orientation when creating a face on an edited asset', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 100, orientation: '6' });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();

View file

@ -68,7 +68,8 @@ describe(SearchService.name, () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
const auth = factory.auth({ user: { id: user.id } });
@ -81,7 +82,8 @@ describe(SearchService.name, () => {
it('should return zero when no assets match the personIds filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person } = await ctx.newPerson({ trustedGroupId: user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user.id });
const auth = factory.auth({ user: { id: user.id } });

View file

@ -22,7 +22,8 @@ describe(SyncEntityType.AssetFaceV2, () => {
it('should detect and sync the first asset face', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
@ -102,7 +103,8 @@ describe(SyncEntityType.AssetFaceV2, () => {
it('should detect and sync the first asset face', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);
@ -181,7 +183,8 @@ describe(SyncEntityType.AssetFaceV2, () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id });
let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]);

View file

@ -0,0 +1,94 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { DB } from 'src/schema';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.PersonUserV1, () => {
it('should detect and sync the first person user', async () => {
const { auth, ctx } = await setup();
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
const { personUser } = await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PersonUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
personId: person.id,
ownerId: auth.user.id,
isHidden: personUser.isHidden,
isFavorite: personUser.isFavorite,
thumbnailFaceAssetId: personUser.thumbnailFaceAssetId,
}),
type: 'PersonUserV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]);
});
it('should detect and sync a deleted person user', async () => {
const { auth, ctx } = await setup();
const personUserRepo = ctx.get(PersonUserRepository);
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id });
await personUserRepo.delete({ personId: person.id, ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PersonUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
personId: person.id,
ownerId: auth.user.id,
},
type: 'PersonUserDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]);
});
it('should not sync a personUser or personUser delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const personUserRepo = ctx.get(PersonUserRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { person } = await ctx.newPerson({ trustedGroupId: user2.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
expect(await ctx.syncStream(auth2, [SyncRequestType.PersonUsersV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.PersonUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]);
await personUserRepo.delete({ personId: person.id, ownerId: user2.id });
expect(await ctx.syncStream(auth2, [SyncRequestType.PersonUsersV1])).toEqual([
expect.objectContaining({ type: SyncEntityType.PersonUserDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PersonUsersV1]);
});
});

View file

@ -18,10 +18,82 @@ beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.PersonV2, () => {
it('should detect and sync the first person', async () => {
const { auth, ctx } = await setup();
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV2]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: person.id,
name: person.name,
birthDate: person.birthDate,
trustedGroupId: auth.user.trustedGroupId,
color: person.color,
}),
type: 'PersonV2',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV2]);
});
it('should detect and sync a deleted person', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
await personRepo.delete([person.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV2]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
personId: person.id,
},
type: 'PersonDeleteV1',
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV2]);
});
it('should not sync a person or person delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { person } = await ctx.newPerson({ trustedGroupId: user2.trustedGroupId });
const auth2 = factory.auth({ session, user: user2 });
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV2])).toEqual([
expect.objectContaining({ type: SyncEntityType.PersonV2 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]);
await personRepo.delete([person.id]);
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV2])).toEqual([
expect.objectContaining({ type: SyncEntityType.PersonDeleteV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV2]);
});
});
describe(SyncEntityType.PersonV1, () => {
it('should detect and sync the first person', async () => {
const { auth, ctx } = await setup();
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
const { personUser } = await ctx.newPersonUser({ personId: person.id, ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
expect(response).toEqual([
@ -30,10 +102,10 @@ describe(SyncEntityType.PersonV1, () => {
data: expect.objectContaining({
id: person.id,
name: person.name,
isHidden: person.isHidden,
isHidden: personUser.isHidden,
birthDate: person.birthDate,
faceAssetId: person.faceAssetId,
isFavorite: person.isFavorite,
faceAssetId: personUser.thumbnailFaceAssetId,
isFavorite: personUser.isFavorite,
ownerId: auth.user.id,
color: person.color,
}),
@ -49,7 +121,7 @@ describe(SyncEntityType.PersonV1, () => {
it('should detect and sync a deleted person', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const { person } = await ctx.newPerson({ trustedGroupId: auth.user.trustedGroupId });
await personRepo.delete([person.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
@ -73,7 +145,8 @@ describe(SyncEntityType.PersonV1, () => {
const personRepo = ctx.get(PersonRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { person } = await ctx.newPerson({ ownerId: user2.id });
const { person } = await ctx.newPerson({ trustedGroupId: user2.trustedGroupId });
await ctx.newPersonUser({ personId: person.id, ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([

View file

@ -91,9 +91,10 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
email = 'test@immich.cloud',
quotaUsageInBytes = 0,
quotaSizeInBytes = null,
trustedGroupId = newUuid(),
} = authUser;
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes, trustedGroupId };
};
const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
@ -109,6 +110,7 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
const {
id = newUuid(),
trustedGroupId = newUuid(),
name = 'Test User',
email = 'test@immich.cloud',
profileImagePath = '',
@ -128,6 +130,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
} = user;
return {
id,
trustedGroupId,
name,
email,
profileImagePath,

View file

@ -48,6 +48,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonUserRepository } from 'src/repositories/person-user.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
@ -259,6 +260,7 @@ export type ServiceOverrides = {
oauth: OAuthRepository;
partner: PartnerRepository;
person: PersonRepository;
personUser: PersonUserRepository;
plugin: PluginRepository;
process: ProcessRepository;
search: SearchRepository;
@ -342,6 +344,7 @@ export const getMocks = () => {
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: automock(PersonRepository, { strict: false }),
personUser: automock(PersonUserRepository, { strict: false }),
plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }),
process: automock(ProcessRepository),
search: automock(SearchRepository, { strict: false }),
@ -410,6 +413,7 @@ export const newTestService = <T extends BaseService>(
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
overrides.ocr || (mocks.ocr as As<OcrRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),
overrides.personUser || (mocks.personUser as As<PersonUserRepository>),
overrides.person || (mocks.person as As<PersonRepository>),
overrides.plugin || (mocks.plugin as As<PluginRepository>),
overrides.process || (mocks.process as As<ProcessRepository>),