feat: show archived assets for a person

This commit is contained in:
martabal
2024-10-15 11:25:28 +02:00
parent e8015dc7d7
commit 882d9bee04
20 changed files with 289 additions and 42 deletions

View File

@@ -12,6 +12,7 @@ import {
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
PersonStatsDto,
PersonUpdateDto,
} from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
@@ -65,8 +66,12 @@ export class PersonController {
@Get(':id/statistics')
@Authenticated({ permission: Permission.PERSON_STATISTICS })
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id);
getPersonStatistics(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: PersonStatsDto,
): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id, dto);
}
@Get(':id/thumbnail')

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator';
import { IsArray, IsBoolean, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator';
import { DateTime } from 'luxon';
import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -41,6 +41,11 @@ export class PersonUpdateDto extends PersonCreateDto {
@Optional()
@IsString()
featureFaceAssetId?: string;
@Optional()
@IsBoolean()
@PropertyLifecycle({ addedAt: 'v1.118.0' })
withArchived?: boolean;
}
export class PeopleUpdateDto {
@@ -93,6 +98,8 @@ export class PersonResponseDto {
isHidden!: boolean;
@PropertyLifecycle({ addedAt: 'v1.107.0' })
updatedAt?: Date;
@PropertyLifecycle({ addedAt: 'v1.118.0' })
withArchived?: boolean;
}
export class PersonWithFacesResponseDto extends PersonResponseDto {
@@ -147,6 +154,11 @@ export class PersonStatisticsResponseDto {
assets!: number;
}
export class PersonStatsDto {
@ValidateBoolean({ optional: true })
withArchived?: boolean;
}
export class PeopleResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
@@ -167,6 +179,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
updatedAt: person.updatedAt,
withArchived: person.withArchived,
};
}

View File

@@ -49,4 +49,7 @@ export class PersonEntity {
@Column({ default: false })
isHidden!: boolean;
@Column({ default: false })
withArchived!: boolean;
}

View File

@@ -45,6 +45,10 @@ export interface DeleteFacesOptions {
sourceType: SourceType;
}
export interface PersonStatsOptions {
withArchived?: boolean;
}
export type UnassignFacesOptions = DeleteFacesOptions;
export interface IPersonRepository {
@@ -74,7 +78,7 @@ export interface IPersonRepository {
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
getStatistics(personId: string): Promise<PersonStatistics>;
getStatistics(personId: string, options: PersonStatsOptions): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddWithArchivedToPerson1728944141526 implements MigrationInterface {
name = 'AddWithArchivedToPerson1728944141526'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "withArchived" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "withArchived"`);
}
}

View File

@@ -212,6 +212,7 @@ SELECT
"8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."withArchived" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_withArchived",
"AssetEntity__AssetEntity_stack"."id" AS "AssetEntity__AssetEntity_stack_id",
"AssetEntity__AssetEntity_stack"."ownerId" AS "AssetEntity__AssetEntity_stack_ownerId",
"AssetEntity__AssetEntity_stack"."primaryAssetId" AS "AssetEntity__AssetEntity_stack_primaryAssetId",

View File

@@ -17,7 +17,8 @@ SELECT
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
"person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM
"person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
@@ -54,7 +55,8 @@ SELECT
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
"person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM
"person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
@@ -83,7 +85,8 @@ SELECT
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
"AssetFaceEntity__AssetFaceEntity_person"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
@@ -116,7 +119,8 @@ FROM
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
"AssetFaceEntity__AssetFaceEntity_person"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
@@ -153,6 +157,7 @@ FROM
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
"AssetFaceEntity__AssetFaceEntity_person"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived",
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
@@ -213,7 +218,8 @@ SELECT
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
"person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM
"person" "person"
WHERE
@@ -242,11 +248,18 @@ FROM
"asset_faces" "face"
LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
AND ("asset"."deletedAt" IS NULL)
LEFT JOIN "person" "person" ON "person"."id" = "face"."personId"
WHERE
"face"."personId" = $1
AND "asset"."isArchived" = false
AND "asset"."deletedAt" IS NULL
AND "asset"."livePhotoVideoId" IS NULL
AND (
(
"person"."withArchived" = false
AND "asset"."isArchived" = false
)
OR "person"."withArchived" = true
)
-- PersonRepository.getNumberOfPeople
SELECT

View File

@@ -17,6 +17,7 @@ import {
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
PersonStatsOptions,
UnassignFacesOptions,
UpdateFacesData,
} from 'src/interfaces/person.interface';
@@ -214,17 +215,33 @@ export class PersonRepository implements IPersonRepository {
return queryBuilder.getMany();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> {
const items = await this.assetFaceRepository
@GenerateSql({ params: [DummyValue.UUID, { withArchived: undefined }] })
async getStatistics(personId: string, options: PersonStatsOptions): Promise<PersonStatistics> {
/*
* withArchived: true -> Return the count of all assets for a given person
* withArchived: false -> Return the count of all unarchived assets for a given person
* withArchived: undefiend ->
* - If person.withArchived = true -> Return the count of all assets for a given person
* - If person.withArchived = false -> Return the count of all unarchived assets for a given person
*/
let queryBuilder = this.assetFaceRepository
.createQueryBuilder('face')
.leftJoin('face.asset', 'asset')
.where('face.personId = :personId', { personId })
.andWhere('asset.isArchived = false')
.andWhere('asset.deletedAt IS NULL')
.andWhere('asset.livePhotoVideoId IS NULL')
.select('COUNT(DISTINCT(asset.id))', 'count')
.getRawOne();
.select('COUNT(DISTINCT(asset.id))', 'count');
if (options.withArchived === false) {
queryBuilder = queryBuilder.andWhere('asset.isArchived = false');
} else if (options.withArchived === undefined) {
queryBuilder = queryBuilder
.leftJoin('face.person', 'person')
.andWhere('((person.withArchived = false AND asset.isArchived = false) OR person.withArchived = true)');
}
const items = await queryBuilder.getRawOne();
return {
assets: items.count ?? 0,
};

View File

@@ -31,6 +31,7 @@ const responseDto: PersonResponseDto = {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
updatedAt: expect.any(Date),
withArchived: false,
};
const statistics = { assets: 3 };
@@ -118,6 +119,7 @@ describe(PersonService.name, () => {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
updatedAt: expect.any(Date),
withArchived: false,
},
],
});
@@ -218,6 +220,16 @@ describe(PersonService.name, () => {
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it("should update a person's withArchived", async () => {
personMock.update.mockResolvedValue(personStub.withName);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { withArchived: true })).resolves.toEqual(responseDto);
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', withArchived: true });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it("should update a person's date of birth", async () => {
personMock.update.mockResolvedValue(personStub.withBirthDate);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@@ -229,6 +241,7 @@ describe(PersonService.name, () => {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
updatedAt: expect.any(Date),
withArchived: false,
});
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
expect(jobMock.queue).not.toHaveBeenCalled();
@@ -381,6 +394,7 @@ describe(PersonService.name, () => {
name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath,
updatedAt: expect.any(Date),
withArchived: personStub.noName.withArchived,
});
expect(jobMock.queue).not.toHaveBeenCalledWith();
@@ -1171,13 +1185,13 @@ describe(PersonService.name, () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getStatistics.mockResolvedValue(statistics);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
await expect(sut.getStatistics(authStub.admin, 'person-1', {})).resolves.toEqual({ assets: 3 });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it('should require person.read permission', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.getStatistics(authStub.admin, 'person-1', {})).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
});

View File

@@ -14,6 +14,7 @@ import {
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
PersonStatsDto,
PersonUpdateDto,
mapFaces,
mapPerson,
@@ -153,9 +154,9 @@ export class PersonService extends BaseService {
return this.findOrFail(id).then(mapPerson);
}
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
async getStatistics(auth: AuthDto, id: string, dto: PersonStatsDto): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
return this.personRepository.getStatistics(id);
return this.personRepository.getStatistics(id, dto);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
@@ -184,7 +185,7 @@ export class PersonService extends BaseService {
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
const { name, birthDate, isHidden, featureFaceAssetId: assetId, withArchived } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
@@ -197,7 +198,14 @@ export class PersonService extends BaseService {
faceId = face.id;
}
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
const person = await this.personRepository.update({
id,
faceAssetId: faceId,
name,
birthDate,
isHidden,
withArchived,
});
if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });