chore(mobile): add Cocoapods to mise (#30722)

* chore(mobile): add Cocoapods to mise

* Use new mise setup in CI
This commit is contained in:
Adam Gastineau 2026-08-12 04:53:14 -07:00 committed by Daniel Dietzler
parent 0344c61e4c
commit d589ddd2c3
No known key found for this signature in database
GPG key ID: A1C0B97CD8E18DFF
38 changed files with 1077 additions and 433 deletions

View file

@ -225,6 +225,13 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install dependencies
run: mise //mobile:install:ci
@ -235,18 +242,6 @@ jobs:
working-directory: ./mobile
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
working-directory: ./mobile/ios
run: |
pod install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

View file

@ -29,7 +29,7 @@ sources = [
]
outputs = { auto = true }
run = [
"dart run build_runner build",
"dart run build_runner build",
"dart format lib/routing/router.gr.dart",
]
depends = ["//:open-api-dart"]
@ -99,7 +99,11 @@ run = "dart run drift_dev make-migrations"
alias = "install"
description = "Install flutter dependencies"
depends = ["//:open-api-dart"]
run = "flutter pub get"
run = [
# This receives passed `--enforce-lockfile` args
"flutter pub get",
{ task = "install:ios" },
]
[tasks."install:ci"]
description = "Install flutter dependencies in CI"
@ -132,6 +136,19 @@ run = [
]
wait_for = ["//:i18n:format-fix"]
[tasks."install:ios"]
description = "Install CocoaPods dependencies"
hide = true
sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"]
outputs = ["ios/Pods/Manifest.lock"]
run = '''
echo "Running pod install"
if [ "$(uname)" != "Darwin" ]; then
exit 0
fi
cd ios && pod install
'''
[tasks."analyze:dart"]
description = "Run Dart analysis"
hide = true

View file

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

View file

@ -26,6 +26,7 @@ export type AuthUser = {
email: string;
quotaUsageInBytes: number;
quotaSizeInBytes: number | null;
trustedGroupId: string;
};
export type AlbumUser = {
@ -130,6 +131,7 @@ export type User = {
avatarColor: UserAvatarColor | null;
profileImagePath: string;
profileChangedAt: Date;
trustedGroupId: string;
};
export type UserAdmin = User & {
@ -241,7 +243,7 @@ export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | '
export type Person = {
createdAt: Date;
id: string;
personId: string;
ownerId: string;
updatedAt: Date;
updateId: string;
@ -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,
@ -552,6 +571,17 @@ export const columns = {
'asset_exif.tags',
'asset_exif.timeZone',
],
person: [
'person_user.personId',
'person_user.userId',
'person_user.name',
'person_user.birthDate',
'person_user.thumbnailPath',
'person_user.isHidden',
'person_user.isFavorite',
'person_user.color',
'person_user.updatedAt',
],
} as const;
export type LockableProperty = (typeof lockableProperties)[number];

View file

@ -171,9 +171,14 @@ 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>,
'personId' | 'name' | 'birthDate' | 'thumbnailPath' | 'isHidden' | 'isFavorite' | 'color' | 'updatedAt'
>,
): PersonResponseDto {
return {
id: person.id,
id: person.personId,
name: person.name,
birthDate: asDateString(person.birthDate),
thumbnailPath: person.thumbnailPath,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -219,6 +219,19 @@ export const person_delete_audit = registerFunction({
END`,
});
export const person_group_delete_audit = registerFunction({
name: 'person_group_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO person_group_audit ("personId", "trustedGroupId")
SELECT "personId", "trustedGroupId"
FROM OLD;
RETURN NULL;
END`,
});
export const user_metadata_audit = registerFunction({
name: 'user_metadata_audit',
returnType: 'TRIGGER',

View file

@ -21,6 +21,7 @@ import {
memory_delete_audit,
partner_delete_audit,
person_delete_audit,
person_group_delete_audit,
stack_delete_audit,
updated_at,
user_delete_audit,
@ -63,6 +64,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 { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table';
import { PersonGroupTable } from 'src/schema/tables/person-group.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';
@ -131,6 +134,8 @@ export class ImmichDatabase {
PartnerTable,
PersonTable,
PersonAuditTable,
PersonGroupTable,
PersonGroupAuditTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
@ -171,6 +176,7 @@ export class ImmichDatabase {
memory_asset_delete_audit,
stack_delete_audit,
person_delete_audit,
person_group_delete_audit,
user_metadata_audit,
asset_metadata_audit,
asset_face_audit,
@ -245,6 +251,8 @@ export interface DB {
person: PersonTable;
person_audit: PersonAuditTable;
person_group: PersonGroupTable;
person_group_audit: PersonGroupAuditTable;
session: SessionTable;
session_sync_checkpoint: SessionSyncCheckpointTable;

View file

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

View file

@ -0,0 +1,38 @@
import {
AfterDeleteTrigger,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_group_delete_audit } from 'src/schema/functions';
import { PersonTable } from 'src/schema/tables/person.table';
import { TrustedGroupTable } from 'src/schema/tables/trusted-group.table';
@Table('person_group')
@UpdatedAtTrigger('person_group_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: person_group_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class PersonGroupTable {
@ForeignKeyColumn(() => PersonTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
id!: string;
@ForeignKeyColumn(() => TrustedGroupTable)
trustedGroupId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View file

@ -6,7 +6,6 @@ import {
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
@ -14,6 +13,7 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonGroupTable } from 'src/schema/tables/person-group.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@ -31,8 +31,13 @@ import { UserTable } from 'src/schema/tables/user.table';
})
@Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` })
export class PersonTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PersonGroupTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
primary: true,
nullable: false,
})
personId!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@ -40,7 +45,7 @@ export class PersonTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true, nullable: false })
ownerId!: string;
@Column({ default: '' })

View file

@ -0,0 +1,25 @@
import {
CreateDateColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators';
@Table('trusted_group')
@UpdatedAtTrigger('trusted_group_updatedAt')
export class TrustedGroupTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View file

@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
@ -14,6 +15,7 @@ import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserAvatarColor, UserStatus } from 'src/enum';
import { user_delete_audit } from 'src/schema/functions';
import { TrustedGroupTable } from 'src/schema/tables/trusted-group.table';
@Table('user')
@UpdatedAtTrigger('user_updatedAt')
@ -82,4 +84,7 @@ export class UserTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@ForeignKeyColumn(() => TrustedGroupTable)
trustedGroupId!: string;
}

View file

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

View file

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

View file

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

View file

@ -24,12 +24,12 @@ import { MediaService } from 'src/services/media.service';
import { AudioStreamInfo, JobCounts, RawImageInfo, VideoFormat, VideoStreamInfo } from 'src/types';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { PersonUserFactory } from 'test/factories/person-user.factory';
import { probeStub } from 'test/fixtures/media.stub';
import { personThumbnailStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { getForGenerateThumbnail } from 'test/mappers';
import { factory, newUuid } from 'test/small.factory';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const fullsizeBuffer = Buffer.from('embedded image data');
@ -53,10 +53,9 @@ describe(MediaService.name, () => {
describe('handleQueueGenerateThumbnails', () => {
it('should queue all assets', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: newUuid() });
const personUser = PersonUserFactory.create();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream([person]));
mocks.personUser.getAll.mockReturnValue(makeStream([personUser]));
await sut.handleQueueGenerateThumbnails({ force: true });
@ -68,11 +67,11 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith(undefined);
expect(mocks.personUser.getAll).toHaveBeenCalledWith(undefined);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: person.id },
data: { personId: personUser.personId, ownerId: personUser.ownerId },
},
]);
});
@ -80,7 +79,7 @@ describe(MediaService.name, () => {
it('should queue trashed assets when force is true', async () => {
const asset = AssetFactory.create({ status: AssetStatus.Trashed, deletedAt: new Date() });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
@ -96,7 +95,7 @@ describe(MediaService.name, () => {
it('should queue archived assets when force is true', async () => {
const asset = AssetFactory.create({ visibility: AssetVisibility.Archive });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
@ -110,26 +109,27 @@ describe(MediaService.name, () => {
});
it('should queue all people with missing thumbnail path', async () => {
const [person1, person2] = [
PersonFactory.create({ thumbnailPath: undefined }),
PersonFactory.create({ thumbnailPath: undefined }),
const [personUser1, personUser2] = [
PersonUserFactory.create({ thumbnailPath: undefined, thumbnailFaceAssetId: null }),
PersonUserFactory.create({ thumbnailPath: undefined, thumbnailFaceAssetId: null }),
];
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()]));
mocks.person.getAll.mockReturnValue(makeStream([person1, person2]));
mocks.personUser.getAll.mockReturnValue(makeStream([personUser1, personUser2]));
mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.person.getRandomFace).toHaveBeenCalled();
expect(mocks.person.update).toHaveBeenCalledTimes(1);
expect(mocks.personUser.update).toHaveBeenCalledTimes(1);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: {
id: person1.id,
personId: personUser1.personId,
ownerId: personUser1.ownerId,
},
},
]);
@ -138,7 +138,7 @@ describe(MediaService.name, () => {
it('should queue all assets with missing resize path', async () => {
const asset = AssetFactory.create();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
@ -149,20 +149,20 @@ describe(MediaService.name, () => {
},
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing preview', async () => {
const asset = AssetFactory.create();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing thumbhash', async () => {
@ -170,7 +170,7 @@ describe(MediaService.name, () => {
.files([AssetFileType.Thumbnail, AssetFileType.Preview])
.build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
@ -178,14 +178,14 @@ describe(MediaService.name, () => {
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
expect(mocks.personUser.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing fullsize when feature is enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
const asset = { id: factory.uuid(), isEdited: false };
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: true });
@ -196,25 +196,25 @@ 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).toHaveBeenCalledTimes(1);
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 });
@ -225,14 +225,14 @@ 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 });
@ -241,14 +241,14 @@ describe(MediaService.name, () => {
{ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } },
]);
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 });
@ -259,13 +259,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 });
@ -280,24 +280,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 } },
]);
});
});
@ -1511,46 +1513,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,
@ -1581,21 +1573,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,
@ -1626,19 +1618,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,
@ -1672,16 +1664,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,
@ -1715,16 +1709,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,
@ -1758,16 +1754,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,
@ -1801,11 +1799,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('');
@ -1814,7 +1812,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, {
@ -1849,31 +1849,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, {
@ -1885,10 +1885,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('');
@ -1897,7 +1897,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, {
@ -1913,7 +1913,7 @@ describe(MediaService.name, () => {
it('should queue all video assets', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
mocks.personUser.getAll.mockReturnValue(makeStream());
await sut.handleQueueVideoConversion({ force: true });

View file

@ -84,23 +84,29 @@ export class MediaService extends BaseService {
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
}
}
await this.jobRepository.queueAll(jobs);
}
for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) {
for await (const people of batched(this.personUserRepository.getAll(force ? undefined : { thumbnailPath: '' }))) {
const jobs: JobItem[] = [];
for (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 },
});
}
await this.jobRepository.queueAll(jobs);
@ -123,9 +129,9 @@ export class MediaService extends BaseService {
);
}
for await (const people of batched(this.personRepository.getAll())) {
for await (const people of batched(this.personUserRepository.getAll())) {
await this.jobRepository.queueAll(
people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })),
people.map(({ personId, ownerId }) => ({ name: JobName.PersonFileMigration, data: { personId, ownerId } })),
);
}
@ -387,19 +393,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;
@ -417,7 +426,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 = {
@ -440,7 +449,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;
}
@ -828,7 +837,7 @@ export class MediaService extends BaseService {
: undefined;
const originalDimensions = getDimensions(asset.exifInfo!);
const assetFaces = await this.personRepository.getFaces(asset.id, {});
const assetFaces = await this.personRepository.getFaces(asset.id);
const ocrData = await this.ocrRepository.getByAssetId(asset.id, {});
const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox);

View file

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

View file

@ -28,7 +28,7 @@ import { ReverseGeocodeResult } from 'src/repositories/map.repository';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PersonUserTable } from 'src/schema/tables/person-user.table';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util';
@ -893,7 +893,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) {
@ -903,8 +909,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;
@ -938,16 +944,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);
@ -966,7 +976,11 @@ export class MetadataService extends BaseService {
}
if (missingWithFaceAsset.length > 0) {
await this.personRepository.updateAll(missingWithFaceAsset);
await this.personUserRepository.updateAll(missingWithFaceAsset);
const jobs = missing.map(
({ personId, ownerId }) => ({ name: JobName.PersonGenerateThumbnail, data: { personId, ownerId } }) as const,
);
await this.jobRepository.queueAll(jobs);
}
}

View file

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

View file

@ -1,6 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Insertable, Updateable } from 'kysely';
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';
@ -37,6 +36,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';
@ -56,17 +56,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)),
@ -78,7 +78,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) {
@ -86,10 +86,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);
}
@ -100,7 +100,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;
}
@ -108,30 +108,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'}`,
);
@ -141,8 +141,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 } });
}
}
@ -151,7 +151,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> {
@ -161,7 +161,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();
}
@ -175,15 +175,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> {
@ -204,19 +208,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> {
@ -245,20 +257,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;
}
@ -470,8 +482,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,
@ -498,7 +516,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,
@ -513,9 +531,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) {
@ -527,13 +550,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;
}
@ -545,7 +571,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[] = [];
@ -570,7 +599,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;
}
@ -588,7 +617,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 });
@ -600,8 +632,8 @@ export class PersonService extends BaseService {
return results;
}
private findOrFail(id: string) {
return findOrFail(() => this.personRepository.getById(id), 'Person');
private findOrFail(id: string, ownerId: string) {
return findOrFail(() => this.personRepository.getForOwner(id, ownerId), 'Person');
}
// TODO return a asset face response
@ -613,7 +645,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) {
@ -672,8 +704,8 @@ export class PersonService extends BaseService {
sourceType: SourceType.Manual,
});
if (!person.faceAssetId) {
await this.createNewFeaturePhoto([person.id]);
if (!person.thumbnailFaceAssetId) {
await this.createNewFeaturePhoto([person.id], person.ownerId);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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]);
});
});