mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 09:14:58 +03:00
* feat: add OCR functionality and related configurations * chore: update labeler configuration for machine learning files * feat(i18n): enhance OCR model descriptions and add orientation classification and unwarping features * chore: update Dockerfile to include ccache for improved build performance * feat(ocr): enhance OCR model configuration with orientation classification and unwarping options, update PaddleOCR integration, and improve response structure * refactor(ocr): remove OCR_CLEANUP job from enum and type definitions * refactor(ocr): remove obsolete OCR entity and migration files, and update asset job status and schema to accommodate new OCR table structure * refactor(ocr): update OCR schema and response structure to use individual coordinates instead of bounding box, and adjust related service and repository files * feat: enhance OCR configuration and functionality - Updated OCR settings to include minimum detection box score, minimum detection score, and minimum recognition score. - Refactored PaddleOCRecognizer to utilize new scoring parameters. - Introduced new database tables for asset OCR data and search functionality. - Modified related services and repositories to support the new OCR features. - Updated translations for improved clarity in settings UI. * sql changes * use rapidocr * change dto * update web * update lock * update api * store positions as normalized floats * match column order in db * update admin ui settings descriptions fix max resolution key set min threshold to 0.1 fix bind * apply config correctly, adjust defaults * unnecessary model type * unnecessary sources * fix(ocr): switch RapidOCR lang type from LangDet to LangRec * fix(ocr): expose lang_type (LangRec.CH) and font_path on OcrOptions for RapidOCR * fix(ocr): make OCR text search case- and accent-insensitive using ILIKE + unaccent * fix(ocr): add OCR search fields * fix: Add OCR database migration and update ML prediction logic. * trigrams are already case insensitive * add tests * format * update migrations * wrong uuid function * linting * maybe fix medium tests * formatting * fix weblate check * openapi * sql * minor fixes * maybe fix medium tests part 2 * passing medium tests * format web * readd sql * format dart * disabled in e2e * chore: translation ordering --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { defaults, SystemConfig } from 'src/config';
|
|
import { ImmichWorker, JobCommand, JobName, JobStatus, QueueName } from 'src/enum';
|
|
import { JobService } from 'src/services/job.service';
|
|
import { JobItem } from 'src/types';
|
|
import { assetStub } from 'test/fixtures/asset.stub';
|
|
import { newTestService, ServiceMocks } from 'test/utils';
|
|
|
|
describe(JobService.name, () => {
|
|
let sut: JobService;
|
|
let mocks: ServiceMocks;
|
|
|
|
beforeEach(() => {
|
|
({ sut, mocks } = newTestService(JobService));
|
|
|
|
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
|
|
});
|
|
|
|
it('should work', () => {
|
|
expect(sut).toBeDefined();
|
|
});
|
|
|
|
describe('onConfigUpdate', () => {
|
|
it('should update concurrency', () => {
|
|
sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
|
|
|
|
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16);
|
|
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1);
|
|
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1);
|
|
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5);
|
|
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.StorageTemplateMigration, 1);
|
|
});
|
|
});
|
|
|
|
describe('handleNightlyJobs', () => {
|
|
it('should run the scheduled jobs', async () => {
|
|
await sut.handleNightlyJobs();
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
{ name: JobName.AssetDeleteCheck },
|
|
{ name: JobName.UserDeleteCheck },
|
|
{ name: JobName.PersonCleanup },
|
|
{ name: JobName.MemoryCleanup },
|
|
{ name: JobName.SessionCleanup },
|
|
{ name: JobName.AuditTableCleanup },
|
|
{ name: JobName.AuditLogCleanup },
|
|
{ name: JobName.MemoryGenerate },
|
|
{ name: JobName.UserSyncUsage },
|
|
{ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } },
|
|
{ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('getAllJobStatus', () => {
|
|
it('should get all job statuses', async () => {
|
|
mocks.job.getJobCounts.mockResolvedValue({
|
|
active: 1,
|
|
completed: 1,
|
|
failed: 1,
|
|
delayed: 1,
|
|
waiting: 1,
|
|
paused: 1,
|
|
});
|
|
mocks.job.getQueueStatus.mockResolvedValue({
|
|
isActive: true,
|
|
isPaused: true,
|
|
});
|
|
|
|
const expectedJobStatus = {
|
|
jobCounts: {
|
|
active: 1,
|
|
completed: 1,
|
|
delayed: 1,
|
|
failed: 1,
|
|
waiting: 1,
|
|
paused: 1,
|
|
},
|
|
queueStatus: {
|
|
isActive: true,
|
|
isPaused: true,
|
|
},
|
|
};
|
|
|
|
await expect(sut.getAllJobsStatus()).resolves.toEqual({
|
|
[QueueName.BackgroundTask]: expectedJobStatus,
|
|
[QueueName.DuplicateDetection]: expectedJobStatus,
|
|
[QueueName.SmartSearch]: expectedJobStatus,
|
|
[QueueName.MetadataExtraction]: expectedJobStatus,
|
|
[QueueName.Search]: expectedJobStatus,
|
|
[QueueName.StorageTemplateMigration]: expectedJobStatus,
|
|
[QueueName.Migration]: expectedJobStatus,
|
|
[QueueName.ThumbnailGeneration]: expectedJobStatus,
|
|
[QueueName.VideoConversion]: expectedJobStatus,
|
|
[QueueName.FaceDetection]: expectedJobStatus,
|
|
[QueueName.FacialRecognition]: expectedJobStatus,
|
|
[QueueName.Sidecar]: expectedJobStatus,
|
|
[QueueName.Library]: expectedJobStatus,
|
|
[QueueName.Notification]: expectedJobStatus,
|
|
[QueueName.BackupDatabase]: expectedJobStatus,
|
|
[QueueName.Ocr]: expectedJobStatus,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleCommand', () => {
|
|
it('should handle a pause command', async () => {
|
|
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Pause, force: false });
|
|
|
|
expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction);
|
|
});
|
|
|
|
it('should handle a resume command', async () => {
|
|
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Resume, force: false });
|
|
|
|
expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction);
|
|
});
|
|
|
|
it('should handle an empty command', async () => {
|
|
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Empty, force: false });
|
|
|
|
expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction);
|
|
});
|
|
|
|
it('should not start a job that is already running', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false });
|
|
|
|
await expect(
|
|
sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle a start video conversion command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } });
|
|
});
|
|
|
|
it('should handle a start storage template migration command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.StorageTemplateMigration, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration });
|
|
});
|
|
|
|
it('should handle a start smart search command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.SmartSearch, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } });
|
|
});
|
|
|
|
it('should handle a start metadata extraction command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
|
name: JobName.AssetExtractMetadataQueueAll,
|
|
data: { force: false },
|
|
});
|
|
});
|
|
|
|
it('should handle a start sidecar command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.Sidecar, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } });
|
|
});
|
|
|
|
it('should handle a start thumbnail generation command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.ThumbnailGeneration, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
|
name: JobName.AssetGenerateThumbnailsQueueAll,
|
|
data: { force: false },
|
|
});
|
|
});
|
|
|
|
it('should handle a start face detection command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.FaceDetection, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } });
|
|
});
|
|
|
|
it('should handle a start facial recognition command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.FacialRecognition, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } });
|
|
});
|
|
|
|
it('should handle a start backup database command', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await sut.handleCommand(QueueName.BackupDatabase, { command: JobCommand.Start, force: false });
|
|
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } });
|
|
});
|
|
|
|
it('should throw a bad request when an invalid queue is used', async () => {
|
|
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
|
|
|
await expect(
|
|
sut.handleCommand(QueueName.BackgroundTask, { command: JobCommand.Start, force: false }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('onJobRun', () => {
|
|
it('should process a successful job', async () => {
|
|
mocks.job.run.mockResolvedValue(JobStatus.Success);
|
|
|
|
const job: JobItem = { name: JobName.FileDelete, data: { files: ['path/to/file'] } };
|
|
await sut.onJobRun(QueueName.BackgroundTask, job);
|
|
|
|
expect(mocks.event.emit).toHaveBeenCalledWith('JobStart', QueueName.BackgroundTask, job);
|
|
expect(mocks.event.emit).toHaveBeenCalledWith('JobSuccess', { job, response: JobStatus.Success });
|
|
expect(mocks.event.emit).toHaveBeenCalledWith('JobComplete', QueueName.BackgroundTask, job);
|
|
expect(mocks.logger.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
const tests: Array<{ item: JobItem; jobs: JobName[]; stub?: any }> = [
|
|
{
|
|
item: { name: JobName.SidecarCheck, data: { id: 'asset-1' } },
|
|
jobs: [JobName.AssetExtractMetadata],
|
|
},
|
|
{
|
|
item: { name: JobName.SidecarCheck, data: { id: 'asset-1' } },
|
|
jobs: [JobName.AssetExtractMetadata],
|
|
},
|
|
{
|
|
item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1', source: 'upload' } },
|
|
jobs: [JobName.AssetGenerateThumbnails],
|
|
},
|
|
{
|
|
item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1' } },
|
|
jobs: [],
|
|
},
|
|
{
|
|
item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } },
|
|
jobs: [],
|
|
},
|
|
{
|
|
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
|
|
jobs: [],
|
|
stub: [assetStub.image],
|
|
},
|
|
{
|
|
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
|
|
jobs: [],
|
|
stub: [assetStub.video],
|
|
},
|
|
{
|
|
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } },
|
|
jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr],
|
|
stub: [assetStub.livePhotoStillAsset],
|
|
},
|
|
{
|
|
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } },
|
|
jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr, JobName.AssetEncodeVideo],
|
|
stub: [assetStub.video],
|
|
},
|
|
{
|
|
item: { name: JobName.SmartSearch, data: { id: 'asset-1' } },
|
|
jobs: [],
|
|
},
|
|
{
|
|
item: { name: JobName.AssetDetectFaces, data: { id: 'asset-1' } },
|
|
jobs: [],
|
|
},
|
|
{
|
|
item: { name: JobName.FacialRecognition, data: { id: 'asset-1' } },
|
|
jobs: [],
|
|
},
|
|
];
|
|
|
|
for (const { item, jobs, stub } of tests) {
|
|
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
|
|
if (stub) {
|
|
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue(stub);
|
|
}
|
|
|
|
mocks.job.run.mockResolvedValue(JobStatus.Success);
|
|
|
|
await sut.onJobRun(QueueName.BackgroundTask, item);
|
|
|
|
if (jobs.length > 1) {
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith(
|
|
jobs.map((jobName) => ({ name: jobName, data: expect.anything() })),
|
|
);
|
|
} else {
|
|
expect(mocks.job.queue).toHaveBeenCalledTimes(jobs.length);
|
|
for (const jobName of jobs) {
|
|
expect(mocks.job.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() });
|
|
}
|
|
}
|
|
});
|
|
|
|
it(`should not queue any jobs when ${item.name} fails`, async () => {
|
|
mocks.job.run.mockResolvedValue(JobStatus.Failed);
|
|
|
|
await sut.onJobRun(QueueName.BackgroundTask, item);
|
|
|
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
|
});
|
|
}
|
|
});
|
|
});
|