Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): e2e for missing jobs #15910

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions e2e/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ services:
ports:
- 2285:2285

immich-machine-learning:
container_name: immich-e2e-machine_learning
image: immich-machine-learning-dev:latest
# extends:
# file: hwaccel.ml.yml
# service: cpu # set to one of [armnn, cuda, openvino, openvino-wsl] for accelerated inference
build:
context: ../machine-learning
dockerfile: Dockerfile
args:
- DEVICE=cpu # set to one of [armnn, cuda, openvino, openvino-wsl] for accelerated inference
ports:
- 3003:3003
volumes:
- ../machine-learning:/usr/src/app
- model-cache:/cache
depends_on:
- database
restart: unless-stopped
healthcheck:
disable: false

redis:
image: redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae

Expand All @@ -45,3 +67,6 @@ services:
POSTGRES_DB: immich
ports:
- 5435:5432

volumes:
model-cache:
5 changes: 1 addition & 4 deletions e2e/src/api/specs/asset.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
AssetResponseDto,
AssetTypeEnum,
getAssetInfo,
getConfig,
getMyUser,
LoginResponseDto,
SharedLinkType,
Expand Down Expand Up @@ -45,8 +44,6 @@ const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-sp
const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
const facesAssetFilepath = `${testAssetDir}/metadata/faces/portrait.jpg`;

const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) });

const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename);
await writeFile(filepath, bytes);
Expand Down Expand Up @@ -228,7 +225,7 @@ describe('/asset', () => {
});

it('should get the asset faces', async () => {
const config = await getSystemConfig(admin.accessToken);
const config = await utils.getSystemConfig(admin.accessToken);
config.metadata.faces.import = true;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });

Expand Down
138 changes: 136 additions & 2 deletions e2e/src/api/specs/jobs.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { JobCommand, JobName, LoginResponseDto } from '@immich/sdk';
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';

Expand All @@ -20,6 +20,16 @@ describe('/jobs', () => {
command: JobCommand.Resume,
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Resume,
force: false,
});
});

it('should require authentication', async () => {
Expand Down Expand Up @@ -82,5 +92,129 @@ describe('/jobs', () => {
expect(asset.exifInfo?.make).toBe('NIKON CORPORATION');
}
});

it('should queue thumbnail extraction for missing assets', async () => {
const path1 = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
const path2 = `${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`;

await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path1), filename: basename(path1) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Pause,
force: false,
});

const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path2), filename: basename(path2) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);

expect(asset.thumbhash).toBeNull();
}

await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Empty,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);

expect(asset.thumbhash).not.toBeNull();
}
});

it('should queue face detection for missing faces', async () => {
const config = await utils.getSystemConfig(admin.accessToken);
config.metadata.faces.import = true;
config.machineLearning.enabled = true;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });

const path = `${testAssetDir}/metadata/faces/solvay.jpg`;

await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Pause,
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.FacialRecognition, {
command: JobCommand.Pause,
force: false,
});

const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, JobName.FaceDetection);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset.people).toEqual([]);
}

await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Empty,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, JobName.FaceDetection);

await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Resume,
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.FacialRecognition, {
command: JobCommand.Resume,
force: false,
});

await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Start,
force: false,
});

await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.waitForQueueFinish(admin.accessToken, JobName.FaceDetection, 60_000);
await utils.waitForQueueFinish(admin.accessToken, JobName.FacialRecognition);

{
const asset = await utils.getAssetInfo(admin.accessToken, id);

expect(asset.unassignedFaces?.length).toBeGreaterThan(10);
}
}, 120_000);
});
});
3 changes: 3 additions & 0 deletions e2e/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
deleteAssets,
getAllJobsStatus,
getAssetInfo,
getConfig,
getConfigDefaults,
login,
searchAssets,
Expand Down Expand Up @@ -414,6 +415,8 @@ export const utils = {
rmSync(path, { recursive: true });
},

getSystemConfig: (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) }),

getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),

checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>
Expand Down
2 changes: 1 addition & 1 deletion e2e/test-assets
13 changes: 9 additions & 4 deletions server/src/repositories/asset.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,13 @@ export class AssetRepository implements IAssetRepository {
)
.$if(property === WithoutProperty.FACES, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('job_status.previewAt', 'is not', null)
.where('job_status.facesRecognizedAt', 'is', null)
.leftJoin('asset_job_status as job_status', 'assetId', 'assets.id')
etnoy marked this conversation as resolved.
Show resolved Hide resolved
.where((eb) =>
eb.or([
eb.and([eb('job_status.previewAt', 'is not', null), eb('job_status.facesRecognizedAt', 'is', null)]),
eb('assetId', 'is', null),
]),
)
.where('assets.isVisible', '=', true),
)
.$if(property === WithoutProperty.SIDECAR, (qb) =>
Expand All @@ -494,10 +498,11 @@ export class AssetRepository implements IAssetRepository {
)
.$if(property === WithoutProperty.THUMBNAIL, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.leftJoin('asset_job_status as job_status', 'assetId', 'assets.id')
etnoy marked this conversation as resolved.
Show resolved Hide resolved
.where('assets.isVisible', '=', true)
.where((eb) =>
eb.or([
eb('assetId', 'is', null),
eb('job_status.previewAt', 'is', null),
eb('job_status.thumbnailAt', 'is', null),
eb('assets.thumbhash', 'is', null),
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/job.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class JobService extends BaseService {
}

async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
this.logger.debug(`Handling command: queue=${queueName},command=${dto.command},force=${dto.force}`);

switch (dto.command) {
case JobCommand.START: {
Expand Down
Loading