fix(server): regenerate missing person thumbnails (#3970)

* Regenerate missing person thumbnails

* Check for empty string instead of zero length

* Remember asset used as person face

* Define entity relation between person and asset via faceAssetId

* Typo

* Fix entity relation

* Tests

* Tests

* Fix code formatting

* Fix import path

* Fix migration

* format

* Fix entity and migration

* Linting

* Remove unneeded cast

* Conventions

* Simplify queries

* Simplify queries

* Remove unneeded typings from entity

* Remove unneeded cast

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Daniele Ricci
2023-09-08 08:49:43 +02:00
committed by GitHub
parent b8777d7739
commit 3432b4625f
13 changed files with 183 additions and 13 deletions

View File

@@ -9,15 +9,19 @@ import {
} from '@app/infra/entities';
import {
assetStub,
faceStub,
newAssetRepositoryMock,
newJobRepositoryMock,
newMediaRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
personStub,
probeStub,
} from '@test';
import { IAssetRepository, WithoutProperty } from '../asset';
import { IJobRepository, JobName } from '../job';
import { IPersonRepository } from '../person';
import { IStorageRepository } from '../storage';
import { ISystemConfigRepository } from '../system-config';
import { IMediaRepository } from './media.repository';
@@ -29,6 +33,7 @@ describe(MediaService.name, () => {
let configMock: jest.Mocked<ISystemConfigRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let mediaMock: jest.Mocked<IMediaRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(async () => {
@@ -36,9 +41,10 @@ describe(MediaService.name, () => {
configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new MediaService(assetMock, jobMock, mediaMock, storageMock, configMock);
sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock);
});
it('should be defined', () => {
@@ -51,6 +57,8 @@ describe(MediaService.name, () => {
items: [assetStub.image],
hasNextPage: false,
});
personMock.getAll.mockResolvedValue([personStub.newThumbnail]);
personMock.getFaceById.mockResolvedValue(faceStub.face1);
await sut.handleQueueGenerateThumbnails({ force: true });
@@ -60,6 +68,57 @@ describe(MediaService.name, () => {
name: JobName.GENERATE_JPEG_THUMBNAIL,
data: { id: assetStub.image.id },
});
expect(personMock.getAll).toHaveBeenCalled();
expect(personMock.getAllWithoutThumbnail).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
imageWidth: faceStub.face1.imageWidth,
imageHeight: faceStub.face1.imageHeight,
boundingBox: {
x1: faceStub.face1.boundingBoxX1,
x2: faceStub.face1.boundingBoxX2,
y1: faceStub.face1.boundingBoxY1,
y2: faceStub.face1.boundingBoxY2,
},
assetId: faceStub.face1.assetId,
personId: personStub.newThumbnail.id,
},
});
});
it('should queue all people with missing thumbnail path', async () => {
assetMock.getWithout.mockResolvedValue({
items: [assetStub.image],
hasNextPage: false,
});
personMock.getAllWithoutThumbnail.mockResolvedValue([personStub.noThumbnail]);
personMock.getRandomFace.mockResolvedValue(faceStub.face1);
await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(personMock.getAll).not.toHaveBeenCalled();
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
expect(personMock.getRandomFace).toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
imageWidth: faceStub.face1.imageWidth,
imageHeight: faceStub.face1.imageHeight,
boundingBox: {
x1: faceStub.face1.boundingBoxX1,
x2: faceStub.face1.boundingBoxX2,
y1: faceStub.face1.boundingBoxY1,
y2: faceStub.face1.boundingBoxY2,
},
assetId: faceStub.face1.assetId,
personId: personStub.newThumbnail.id,
},
});
});
it('should queue all assets with missing resize path', async () => {
@@ -67,6 +126,7 @@ describe(MediaService.name, () => {
items: [assetStub.noResizePath],
hasNextPage: false,
});
personMock.getAllWithoutThumbnail.mockResolvedValue([]);
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -76,6 +136,9 @@ describe(MediaService.name, () => {
name: JobName.GENERATE_JPEG_THUMBNAIL,
data: { id: assetStub.image.id },
});
expect(personMock.getAll).not.toHaveBeenCalled();
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
});
it('should queue all assets with missing webp path', async () => {
@@ -83,6 +146,7 @@ describe(MediaService.name, () => {
items: [assetStub.noWebpPath],
hasNextPage: false,
});
personMock.getAllWithoutThumbnail.mockResolvedValue([]);
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -92,6 +156,9 @@ describe(MediaService.name, () => {
name: JobName.GENERATE_WEBP_THUMBNAIL,
data: { id: assetStub.image.id },
});
expect(personMock.getAll).not.toHaveBeenCalled();
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
});
it('should queue all assets with missing thumbhash', async () => {
@@ -99,6 +166,7 @@ describe(MediaService.name, () => {
items: [assetStub.noThumbhash],
hasNextPage: false,
});
personMock.getAllWithoutThumbnail.mockResolvedValue([]);
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -108,6 +176,9 @@ describe(MediaService.name, () => {
name: JobName.GENERATE_THUMBHASH_THUMBNAIL,
data: { id: assetStub.image.id },
});
expect(personMock.getAll).not.toHaveBeenCalled();
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
});
});
@@ -245,6 +316,7 @@ describe(MediaService.name, () => {
items: [assetStub.video],
hasNextPage: false,
});
personMock.getAll.mockResolvedValue([]);
await sut.handleQueueVideoConversion({ force: true });

View File

@@ -4,11 +4,13 @@ import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset';
import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { IPersonRepository } from '../person';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from './media.repository';
import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIConfig, VP9Config } from './media.util';
@Injectable()
export class MediaService {
private logger = new Logger(MediaService.name);
@@ -17,6 +19,7 @@ export class MediaService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@@ -49,6 +52,32 @@ export class MediaService {
}
}
const people = force ? await this.personRepository.getAll() : await this.personRepository.getAllWithoutThumbnail();
for (const person of people) {
// use stored asset for generating thumbnail or pick a random one if not present
const face = person.faceAssetId
? await this.personRepository.getFaceById({ personId: person.id, assetId: person.faceAssetId })
: await this.personRepository.getRandomFace(person.id);
if (face) {
await this.jobRepository.queue({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
imageWidth: face.imageWidth,
imageHeight: face.imageHeight,
boundingBox: {
x1: face.boundingBoxX1,
x2: face.boundingBoxX2,
y1: face.boundingBoxY1,
y2: face.boundingBoxY2,
},
assetId: face.assetId,
personId: person.id,
},
});
}
}
return true;
}