mirror of
https://github.com/immich-app/immich.git
synced 2026-01-19 00:05:50 -08:00
feat: show archived assets for a person
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,7 @@ export class PersonEntity {
|
||||
|
||||
@Column({ default: false })
|
||||
isHidden!: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
withArchived!: boolean;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user