diff --git a/docs/docs/developer/database-migrations.md b/docs/docs/developer/database-migrations.md index f94e2ca4f2..6753e864cb 100644 --- a/docs/docs/developer/database-migrations.md +++ b/docs/docs/developer/database-migrations.md @@ -9,6 +9,6 @@ npm run typeorm:migrations:generate ./src/infra/ ``` 2. Check if the migration file makes sense. -3. Move the migration file to folder `./src/infra/database/migrations` in your code editor. +3. Move the migration file to folder `./server/src/infra/migrations` in your code editor. The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately. diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 59b33f4dc6..3a4ac6b6db 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -467,6 +467,8 @@ describe(PersonService.name, () => { }); it('should handle no results', async () => { + const start = Date.now(); + machineLearningMock.detectFaces.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleRecognizeFaces({ id: assetStub.image.id }); @@ -485,6 +487,12 @@ describe(PersonService.name, () => { ); expect(personMock.createFace).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); + + expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + assetId: assetStub.image.id, + facesRecognizedAt: expect.any(Date), + }); + expect(assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt?.getTime()).toBeGreaterThan(start); }); it('should match existing people', async () => { diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 0d8f9b6b89..49329a452a 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -274,6 +274,11 @@ export class PersonService { } } + await this.assetRepository.upsertJobStatus({ + assetId: asset.id, + facesRecognizedAt: new Date(), + }); + return true; } diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 4b11cb55a0..da8f8547eb 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,4 +1,4 @@ -import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; +import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { FindOptionsRelations } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; @@ -126,4 +126,5 @@ export interface IAssetRepository { getTimeBuckets(options: TimeBucketOptions): Promise; getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; upsertExif(exif: Partial): Promise; + upsertJobStatus(jobStatus: Partial): Promise; } diff --git a/server/src/infra/entities/asset-job-status.entity.ts b/server/src/infra/entities/asset-job-status.entity.ts new file mode 100644 index 0000000000..36905cc8f3 --- /dev/null +++ b/server/src/infra/entities/asset-job-status.entity.ts @@ -0,0 +1,15 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; +import { AssetEntity } from './asset.entity'; + +@Entity('asset_job_status') +export class AssetJobStatusEntity { + @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @JoinColumn() + asset!: AssetEntity; + + @PrimaryColumn() + assetId!: string; + + @Column({ type: 'timestamptz', nullable: true }) + facesRecognizedAt!: Date | null; +} diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 937107f9d1..93050b23cc 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -15,6 +15,7 @@ import { } from 'typeorm'; import { AlbumEntity } from './album.entity'; import { AssetFaceEntity } from './asset-face.entity'; +import { AssetJobStatusEntity } from './asset-job-status.entity'; import { ExifEntity } from './exif.entity'; import { LibraryEntity } from './library.entity'; import { SharedLinkEntity } from './shared-link.entity'; @@ -158,6 +159,9 @@ export class AssetEntity { @OneToMany(() => AssetEntity, (asset) => asset.stackParent) stack?: AssetEntity[]; + + @OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true }) + jobStatus?: AssetJobStatusEntity; } export enum AssetType { diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index cbe2bf6c37..e4b5c38b4d 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -2,6 +2,7 @@ import { ActivityEntity } from './activity.entity'; import { AlbumEntity } from './album.entity'; import { APIKeyEntity } from './api-key.entity'; import { AssetFaceEntity } from './asset-face.entity'; +import { AssetJobStatusEntity } from './asset-job-status.entity'; import { AssetEntity } from './asset.entity'; import { AuditEntity } from './audit.entity'; import { ExifEntity } from './exif.entity'; @@ -20,6 +21,7 @@ export * from './activity.entity'; export * from './album.entity'; export * from './api-key.entity'; export * from './asset-face.entity'; +export * from './asset-job-status.entity'; export * from './asset.entity'; export * from './audit.entity'; export * from './exif.entity'; @@ -40,6 +42,7 @@ export const databaseEntities = [ APIKeyEntity, AssetEntity, AssetFaceEntity, + AssetJobStatusEntity, AuditEntity, ExifEntity, MoveEntity, diff --git a/server/src/infra/migrations/1699345863886-AddJobStatus.ts b/server/src/infra/migrations/1699345863886-AddJobStatus.ts new file mode 100644 index 0000000000..c7df6387c0 --- /dev/null +++ b/server/src/infra/migrations/1699345863886-AddJobStatus.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddJobStatus1699345863886 implements MigrationInterface { + name = 'AddJobStatus1699345863886' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "asset_job_status" ("assetId" uuid NOT NULL, "facesRecognizedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_420bec36fc02813bddf5c8b73d4" PRIMARY KEY ("assetId"))`); + await queryRunner.query(`ALTER TABLE "asset_job_status" ADD CONSTRAINT "FK_420bec36fc02813bddf5c8b73d4" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_job_status" DROP CONSTRAINT "FK_420bec36fc02813bddf5c8b73d4"`); + await queryRunner.query(`DROP TABLE "asset_job_status"`); + } + +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 199842ed45..5112fc9f65 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -20,7 +20,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DateTime } from 'luxon'; import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm'; -import { AssetEntity, AssetType, ExifEntity } from '../entities'; +import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '../entities'; import OptionalBetween from '../utils/optional-between.util'; import { paginate } from '../utils/pagination.util'; @@ -39,12 +39,17 @@ export class AssetRepository implements IAssetRepository { constructor( @InjectRepository(AssetEntity) private repository: Repository, @InjectRepository(ExifEntity) private exifRepository: Repository, + @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, ) {} async upsertExif(exif: Partial): Promise { await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] }); } + async upsertJobStatus(jobStatus: Partial): Promise { + await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); + } + create(asset: AssetCreate): Promise { return this.repository.save(asset); } @@ -323,6 +328,7 @@ export class AssetRepository implements IAssetRepository { case WithoutProperty.FACES: relations = { faces: true, + jobStatus: true, }; where = { resizePath: Not(IsNull()), @@ -331,6 +337,9 @@ export class AssetRepository implements IAssetRepository { assetId: IsNull(), personId: IsNull(), }, + jobStatus: { + facesRecognizedAt: IsNull(), + }, }; break; diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index a6f8f48e75..7736f085fe 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -6,9 +6,12 @@ import { LoginResponseDto, SharedLinkResponseDto, TimeBucketSize, + WithoutProperty, + usePagination, } from '@app/domain'; import { AssetController } from '@app/immich'; import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities'; +import { AssetRepository } from '@app/infra/repositories'; import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { errorStub, uuidStub } from '@test/fixtures'; @@ -788,4 +791,55 @@ describe(`${AssetController.name} (e2e)`, () => { expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); }); }); + + describe(AssetRepository.name, () => { + describe('getWithout', () => { + describe('WithoutProperty.FACES', () => { + const getAssetIdsWithoutFaces = async () => { + const assetPagination = usePagination(10, (pagination) => + assetRepository.getWithout(pagination, WithoutProperty.FACES), + ); + let assets: AssetEntity[] = []; + for await (const assetsPage of assetPagination) { + assets = [...assets, ...assetsPage]; + } + return assets.map((a) => a.id); + }; + + beforeEach(async () => { + await assetRepository.save({ id: asset1.id, resizePath: '/path/to/resize' }); + expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); + }); + + describe('with recognized faces', () => { + beforeEach(async () => { + const personRepository = app.get(IPersonRepository); + const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); + await personRepository.createFace({ assetId: asset1.id, personId: person.id }); + }); + + it('should not return asset with facesRecognizedAt unset', async () => { + expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); + }); + + it('should not return asset with facesRecognizedAt set', async () => { + await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); + expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); + }); + }); + + describe('without recognized faces', () => { + it('should return asset with facesRecognizedAt unset', async () => { + expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); + }); + + it('should not return asset with facesRecognizedAt set', async () => { + expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); + await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); + expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); + }); + }); + }); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index c185d3983d..3b584e09c8 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -4,6 +4,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { return { create: jest.fn(), upsertExif: jest.fn(), + upsertJobStatus: jest.fn(), getByDate: jest.fn(), getByDayOfYear: jest.fn(), getByIds: jest.fn().mockResolvedValue([]),