mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge 5fc7233e39 into 4c4170077c
This commit is contained in:
commit
25bf5d3c9d
74 changed files with 1987 additions and 659 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ select
|
|||
"user"."email",
|
||||
"user"."isAdmin",
|
||||
"user"."quotaUsageInBytes",
|
||||
"user"."quotaSizeInBytes"
|
||||
"user"."quotaSizeInBytes",
|
||||
"user"."trustedGroupId"
|
||||
from
|
||||
"user"
|
||||
where
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
132
server/src/queries/person.user.repository.sql
Normal file
132
server/src/queries/person.user.repository.sql
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ select
|
|||
"user"."email",
|
||||
"user"."isAdmin",
|
||||
"user"."quotaUsageInBytes",
|
||||
"user"."quotaSizeInBytes"
|
||||
"user"."quotaSizeInBytes",
|
||||
"user"."trustedGroupId"
|
||||
from
|
||||
"user"
|
||||
where
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
191
server/src/repositories/person-user.repository.ts
Normal file
191
server/src/repositories/person-user.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
17
server/src/schema/tables/person-user-audit.table.ts
Normal file
17
server/src/schema/tables/person-user-audit.table.ts
Normal 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>;
|
||||
}
|
||||
52
server/src/schema/tables/person-user.table.ts
Normal file
52
server/src/schema/tables/person-user.table.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
47
server/test/factories/person-user.factory.ts
Normal file
47
server/test/factories/person-user.factory.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class UserFactory {
|
|||
status: UserStatus.Active,
|
||||
profileChangedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
trustedGroupId: newUuid(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
3
server/test/fixtures/auth.stub.ts
vendored
3
server/test/fixtures/auth.stub.ts
vendored
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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']>) => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
94
server/test/medium/specs/sync/sync-person-user.spec.ts
Normal file
94
server/test/medium/specs/sync/sync-person-user.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue