1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server): job handlers (#2572)

* refactor(server): job handlers

* chore: remove comment

* chore: add comments for
This commit is contained in:
Jason Rasmussen 2023-05-26 15:43:24 -04:00 committed by GitHub
parent d6756f3d81
commit 1c2d83e2c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 807 additions and 1082 deletions

View File

@ -1,5 +1,5 @@
import { AuthUserDto, IJobRepository, JobName } from '@app/domain'; import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
import { AssetEntity, UserEntity } from '@app/infra/entities'; import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
import { IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { parse } from 'node:path'; import { parse } from 'node:path';
@ -43,7 +43,10 @@ export class AssetCore {
sidecarPath: sidecarFile?.originalPath || null, sidecarPath: sidecarFile?.originalPath || null,
}); });
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset } }); await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
}
return asset; return asset;
} }

View File

@ -328,8 +328,9 @@ describe('AssetService', () => {
}); });
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoMotionAsset } }], [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoStillAsset } }], [{ name: JobName.VIDEO_CONVERSION, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: assetEntityStub.livePhotoStillAsset.id } }],
]); ]);
}); });
}); });

View File

@ -1,7 +1,9 @@
import { import {
AssetService,
FacialRecognitionService, FacialRecognitionService,
IDeleteFilesJob,
JobItem,
JobName, JobName,
JobService,
JOBS_TO_QUEUE, JOBS_TO_QUEUE,
MediaService, MediaService,
MetadataService, MetadataService,
@ -16,12 +18,12 @@ import {
UserService, UserService,
} from '@app/domain'; } from '@app/domain';
import { getQueueToken } from '@nestjs/bull'; import { getQueueToken } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
type JobHandler<T = any> = (data: T) => void | Promise<void>; type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
@Injectable() @Injectable()
export class ProcessorService { export class ProcessorService {
@ -30,8 +32,8 @@ export class ProcessorService {
// TODO refactor to domain // TODO refactor to domain
private metadataProcessor: MetadataExtractionProcessor, private metadataProcessor: MetadataExtractionProcessor,
private assetService: AssetService,
private facialRecognitionService: FacialRecognitionService, private facialRecognitionService: FacialRecognitionService,
private jobService: JobService,
private mediaService: MediaService, private mediaService: MediaService,
private metadataService: MetadataService, private metadataService: MetadataService,
private personService: PersonService, private personService: PersonService,
@ -43,9 +45,10 @@ export class ProcessorService {
private userService: UserService, private userService: UserService,
) {} ) {}
private logger = new Logger(ProcessorService.name);
private handlers: Record<JobName, JobHandler> = { private handlers: Record<JobName, JobHandler> = {
[JobName.ASSET_UPLOADED]: (data) => this.assetService.handleAssetUpload(data), [JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.DELETE_FILES]: (data) => this.storageService.handleDeleteFiles(data),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(), [JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data), [JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data), [JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
@ -71,15 +74,14 @@ export class ProcessorService {
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
[JobName.EXIF_EXTRACTION]: (data) => this.metadataProcessor.extractExifInfo(data), [JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
[JobName.EXTRACT_VIDEO_METADATA]: (data) => this.metadataProcessor.extractVideoMetadata(data),
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data), [JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data), [JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data), [JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(), [JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data), [JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data), [JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
}; };
async init() { async init() {
@ -98,7 +100,14 @@ export class ProcessorService {
await queue.isReady(); await queue.isReady();
queue.process(jobName, concurrency, async (job): Promise<void> => { queue.process(jobName, concurrency, async (job): Promise<void> => {
await handler(job.data); try {
const success = await handler(job.data);
if (success) {
await this.jobService.onDone({ name: jobName, data: job.data } as JobItem);
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler: ${error}`, error?.stack, job.data);
}
}); });
} }
} }

View File

@ -1,8 +1,7 @@
import { import {
AssetCore,
IAssetJob,
IAssetRepository, IAssetRepository,
IBaseJob, IBaseJob,
IEntityJob,
IGeocodingRepository, IGeocodingRepository,
IJobRepository, IJobRepository,
JobName, JobName,
@ -32,7 +31,6 @@ interface ImmichTags extends Tags {
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private logger = new Logger(MetadataExtractionProcessor.name); private logger = new Logger(MetadataExtractionProcessor.name);
private assetCore: AssetCore;
private reverseGeocodingEnabled: boolean; private reverseGeocodingEnabled: boolean;
constructor( constructor(
@ -43,7 +41,6 @@ export class MetadataExtractionProcessor {
configService: ConfigService, configService: ConfigService,
) { ) {
this.assetCore = new AssetCore(assetRepository, jobRepository);
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING'); this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
} }
@ -70,7 +67,6 @@ export class MetadataExtractionProcessor {
} }
async handleQueueMetadataExtraction(job: IBaseJob) { async handleQueueMetadataExtraction(job: IBaseJob) {
try {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
@ -80,19 +76,27 @@ export class MetadataExtractionProcessor {
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION; await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
await this.jobRepository.queue({ name, data: { asset } });
}
}
} catch (error: any) {
this.logger.error(`Unable to queue metadata extraction`, error?.stack);
} }
} }
async extractExifInfo(job: IAssetJob) { return true;
let asset = job.asset; }
try { async handleMetadataExtraction({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || !asset.isVisible) {
return false;
}
if (asset.type === AssetType.VIDEO) {
return this.handleVideoMetadataExtraction(asset);
} else {
return this.handlePhotoMetadataExtraction(asset);
}
}
private async handlePhotoMetadataExtraction(asset: AssetEntity) {
const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => { const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
this.logger.warn( this.logger.warn(
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`, `The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
@ -100,6 +104,7 @@ export class MetadataExtractionProcessor {
); );
return null; return null;
}); });
const sidecarExifData = asset.sidecarPath const sidecarExifData = asset.sidecarPath
? await exiftool.read<ImmichTags>(asset.sidecarPath).catch((error: any) => { ? await exiftool.read<ImmichTags>(asset.sidecarPath).catch((error: any) => {
this.logger.warn( this.logger.warn(
@ -172,15 +177,15 @@ export class MetadataExtractionProcessor {
newExif.livePhotoCID = getExifProperty('MediaGroupUUID'); newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
if (newExif.livePhotoCID && !asset.livePhotoVideoId) { if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
const motionAsset = await this.assetCore.findLivePhotoMatch({ const motionAsset = await this.assetRepository.findLivePhotoMatch({
livePhotoCID: newExif.livePhotoCID, livePhotoCID: newExif.livePhotoCID,
otherAssetId: asset.id, otherAssetId: asset.id,
ownerId: asset.ownerId, ownerId: asset.ownerId,
type: AssetType.VIDEO, type: AssetType.VIDEO,
}); });
if (motionAsset) { if (motionAsset) {
await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
await this.assetCore.save({ id: motionAsset.id, isVisible: false }); await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
} }
} }
@ -207,24 +212,12 @@ export class MetadataExtractionProcessor {
} }
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
asset = await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() }); await this.assetRepository.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() });
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { asset } });
} catch (error: any) { return true;
this.logger.error(
`Error extracting EXIF ${error} for assetId ${asset.id} at ${asset.originalPath}`,
error?.stack,
);
}
} }
async extractVideoMetadata(job: IAssetJob) { private async handleVideoMetadataExtraction(asset: AssetEntity) {
let asset = job.asset;
if (!asset.isVisible) {
return;
}
try {
const data = await ffprobe(asset.originalPath); const data = await ffprobe(asset.originalPath);
const durationString = this.extractDuration(data.format.duration || asset.duration); const durationString = this.extractDuration(data.format.duration || asset.duration);
let fileCreatedAt = asset.fileCreatedAt; let fileCreatedAt = asset.fileCreatedAt;
@ -261,15 +254,15 @@ export class MetadataExtractionProcessor {
newExif.livePhotoCID = exifData?.ContentIdentifier || null; newExif.livePhotoCID = exifData?.ContentIdentifier || null;
if (newExif.livePhotoCID) { if (newExif.livePhotoCID) {
const photoAsset = await this.assetCore.findLivePhotoMatch({ const photoAsset = await this.assetRepository.findLivePhotoMatch({
livePhotoCID: newExif.livePhotoCID, livePhotoCID: newExif.livePhotoCID,
ownerId: asset.ownerId, ownerId: asset.ownerId,
otherAssetId: asset.id, otherAssetId: asset.id,
type: AssetType.IMAGE, type: AssetType.IMAGE,
}); });
if (photoAsset) { if (photoAsset) {
await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
await this.assetCore.save({ id: asset.id, isVisible: false }); await this.assetRepository.save({ id: asset.id, isVisible: false });
} }
} }
@ -327,14 +320,9 @@ export class MetadataExtractionProcessor {
} }
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
asset = await this.assetCore.save({ id: asset.id, duration: durationString, fileCreatedAt }); await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { asset } });
} catch (error: any) { return true;
this.logger.error(
`Error in video metadata extraction due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
error?.stack,
);
}
} }
private async applyReverseGeocoding(asset: AssetEntity, newExif: ExifEntity) { private async applyReverseGeocoding(asset: AssetEntity, newExif: ExifEntity) {

View File

@ -1,20 +0,0 @@
import { AssetEntity } from '@app/infra/entities';
import { IJobRepository, JobName } from '../job';
import { IAssetRepository, LivePhotoSearchOptions } from './asset.repository';
export class AssetCore {
constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {}
async save(asset: Partial<AssetEntity>) {
const _asset = await this.assetRepository.save(asset);
await this.jobRepository.queue({
name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [_asset.id] },
});
return _asset;
}
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null> {
return this.assetRepository.findLivePhotoMatch(options);
}
}

View File

@ -1,12 +1,9 @@
import { AssetEntity, AssetType } from '@app/infra/entities'; import { assetEntityStub, authStub, newAssetRepositoryMock } from '../../test';
import { assetEntityStub, authStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { AssetService, IAssetRepository } from '../asset'; import { AssetService, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
describe(AssetService.name, () => { describe(AssetService.name, () => {
let sut: AssetService; let sut: AssetService;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
@ -14,49 +11,7 @@ describe(AssetService.name, () => {
beforeEach(async () => { beforeEach(async () => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock(); sut = new AssetService(assetMock);
sut = new AssetService(assetMock, jobMock);
});
describe(`handle asset upload`, () => {
it('should process an uploaded video', async () => {
const data = { asset: { type: AssetType.VIDEO } as AssetEntity };
await expect(sut.handleAssetUpload(data)).resolves.toBeUndefined();
expect(jobMock.queue).toHaveBeenCalledTimes(3);
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }],
[{ name: JobName.VIDEO_CONVERSION, data }],
[{ name: JobName.EXTRACT_VIDEO_METADATA, data }],
]);
});
it('should process an uploaded image', async () => {
const data = { asset: { type: AssetType.IMAGE } as AssetEntity };
await sut.handleAssetUpload(data);
expect(jobMock.queue).toHaveBeenCalledTimes(2);
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }],
[{ name: JobName.EXIF_EXTRACTION, data }],
]);
});
});
describe('save', () => {
it('should save an asset', async () => {
assetMock.save.mockResolvedValue(assetEntityStub.image);
await sut.save(assetEntityStub.image);
expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetEntityStub.image.id] },
});
});
}); });
describe('get map markers', () => { describe('get map markers', () => {

View File

@ -1,36 +1,11 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { IAssetJob, IJobRepository, JobName } from '../job';
import { AssetCore } from './asset.core';
import { IAssetRepository } from './asset.repository'; import { IAssetRepository } from './asset.repository';
import { MapMarkerDto } from './dto/map-marker.dto'; import { MapMarkerDto } from './dto/map-marker.dto';
import { MapMarkerResponseDto } from './response-dto'; import { MapMarkerResponseDto } from './response-dto';
export class AssetService { export class AssetService {
private assetCore: AssetCore; constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.assetCore = new AssetCore(assetRepository, jobRepository);
}
async handleAssetUpload(data: IAssetJob) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data });
if (data.asset.type == AssetType.VIDEO) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data });
await this.jobRepository.queue({ name: JobName.EXTRACT_VIDEO_METADATA, data });
} else {
await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data });
}
}
save(asset: Partial<AssetEntity>) {
return this.assetCore.save(asset);
}
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> { getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options); return this.assetRepository.getMapMarkers(authUser.id, options);

View File

@ -1,4 +1,3 @@
export * from './asset.core';
export * from './asset.repository'; export * from './asset.repository';
export * from './asset.service'; export * from './asset.service';
export * from './response-dto'; export * from './response-dto';

View File

@ -141,7 +141,7 @@ describe(FacialRecognitionService.name, () => {
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.RECOGNIZE_FACES, name: JobName.RECOGNIZE_FACES,
data: { asset: assetEntityStub.image }, data: { id: assetEntityStub.image.id },
}); });
}); });
@ -158,25 +158,22 @@ describe(FacialRecognitionService.name, () => {
expect(assetMock.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.RECOGNIZE_FACES, name: JobName.RECOGNIZE_FACES,
data: { asset: assetEntityStub.image }, data: { id: assetEntityStub.image.id },
}); });
}); });
it('should log an error', async () => {
assetMock.getWithout.mockRejectedValue(new Error('Database unavailable'));
await sut.handleQueueRecognizeFaces({});
});
}); });
describe('handleRecognizeFaces', () => { describe('handleRecognizeFaces', () => {
it('should skip when no resize path', async () => { it('should skip when no resize path', async () => {
await sut.handleRecognizeFaces({ asset: assetEntityStub.noResizePath }); assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
await sut.handleRecognizeFaces({ id: assetEntityStub.noResizePath.id });
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
}); });
it('should handle no results', async () => { it('should handle no results', async () => {
machineLearningMock.detectFaces.mockResolvedValue([]); machineLearningMock.detectFaces.mockResolvedValue([]);
await sut.handleRecognizeFaces({ asset: assetEntityStub.image }); assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({ expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({
thumbnailPath: assetEntityStub.image.resizePath, thumbnailPath: assetEntityStub.image.resizePath,
}); });
@ -187,26 +184,23 @@ describe(FacialRecognitionService.name, () => {
it('should match existing people', async () => { it('should match existing people', async () => {
machineLearningMock.detectFaces.mockResolvedValue([face.middle]); machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch); searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
await sut.handleRecognizeFaces({ asset: assetEntityStub.image }); await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
expect(faceMock.create).toHaveBeenCalledWith({ expect(faceMock.create).toHaveBeenCalledWith({
personId: 'person-1', personId: 'person-1',
assetId: 'asset-id', assetId: 'asset-id',
embedding: [1, 2, 3, 4], embedding: [1, 2, 3, 4],
}); });
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
[{ name: JobName.SEARCH_INDEX_ASSET, data: { ids: ['asset-id'] } }],
]);
}); });
it('should create a new person', async () => { it('should create a new person', async () => {
machineLearningMock.detectFaces.mockResolvedValue([face.middle]); machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch); searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
personMock.create.mockResolvedValue(personStub.noName); personMock.create.mockResolvedValue(personStub.noName);
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
await sut.handleRecognizeFaces({ asset: assetEntityStub.image }); await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId }); expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId });
expect(faceMock.create).toHaveBeenCalledWith({ expect(faceMock.create).toHaveBeenCalledWith({
@ -234,14 +228,8 @@ describe(FacialRecognitionService.name, () => {
}, },
], ],
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }], [{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
[{ name: JobName.SEARCH_INDEX_ASSET, data: { ids: ['asset-id'] } }],
]); ]);
}); });
it('should log an error', async () => {
machineLearningMock.detectFaces.mockRejectedValue(new Error('machine learning unavailable'));
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
});
}); });
describe('handleGenerateFaceThumbnail', () => { describe('handleGenerateFaceThumbnail', () => {
@ -317,10 +305,5 @@ describe(FacialRecognitionService.name, () => {
size: 250, size: 250,
}); });
}); });
it('should log an error', async () => {
assetMock.getByIds.mockRejectedValue(new Error('Database unavailable'));
await sut.handleGenerateFaceThumbnail(face.middle);
});
}); });
}); });

View File

@ -3,7 +3,7 @@ import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
import { MACHINE_LEARNING_ENABLED } from '../domain.constant'; import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IAssetJob, IBaseJob, IFaceThumbnailJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { IBaseJob, IEntityJob, IFaceThumbnailJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media'; import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
import { IPersonRepository } from '../person/person.repository'; import { IPersonRepository } from '../person/person.repository';
import { ISearchRepository } from '../search/search.repository'; import { ISearchRepository } from '../search/search.repository';
@ -27,7 +27,6 @@ export class FacialRecognitionService {
) {} ) {}
async handleQueueRecognizeFaces({ force }: IBaseJob) { async handleQueueRecognizeFaces({ force }: IBaseJob) {
try {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination) ? this.assetRepository.getAll(pagination)
@ -39,24 +38,22 @@ export class FacialRecognitionService {
const faces = await this.searchRepository.deleteAllFaces(); const faces = await this.searchRepository.deleteAllFaces();
this.logger.debug(`Deleted ${people} people and ${faces} faces`); this.logger.debug(`Deleted ${people} people and ${faces} faces`);
} }
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } }); await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { id: asset.id } });
}
}
} catch (error: any) {
this.logger.error(`Unable to queue recognize faces`, error?.stack);
} }
} }
async handleRecognizeFaces(data: IAssetJob) { return true;
const { asset } = data; }
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { async handleRecognizeFaces({ id }: IEntityJob) {
return; const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || !MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return false;
} }
try {
const faces = await this.machineLearning.detectFaces({ thumbnailPath: asset.resizePath }); const faces = await this.machineLearning.detectFaces({ thumbnailPath: asset.resizePath });
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`); this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
@ -88,23 +85,17 @@ export class FacialRecognitionService {
await this.faceRepository.create({ ...faceId, embedding }); await this.faceRepository.create({ ...faceId, embedding });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
} }
// queue all faces for asset return true;
} catch (error: any) {
this.logger.error(`Unable run facial recognition pipeline: ${asset.id}`, error?.stack);
}
} }
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) { async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
const { assetId, personId, boundingBox, imageWidth, imageHeight } = data; const { assetId, personId, boundingBox, imageWidth, imageHeight } = data;
try {
const [asset] = await this.assetRepository.getByIds([assetId]); const [asset] = await this.assetRepository.getByIds([assetId]);
if (!asset || !asset.resizePath) { if (!asset || !asset.resizePath) {
this.logger.warn(`Asset not found for facial cropping: ${assetId}`); return false;
return;
} }
this.logger.verbose(`Cropping face for person: ${personId}`); this.logger.verbose(`Cropping face for person: ${personId}`);
@ -142,8 +133,7 @@ export class FacialRecognitionService {
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions); const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
await this.mediaRepository.resize(croppedOutput, output, { size: FACE_THUMBNAIL_SIZE, format: 'jpeg' }); await this.mediaRepository.resize(croppedOutput, output, { size: FACE_THUMBNAIL_SIZE, format: 'jpeg' });
await this.personRepository.update({ id: personId, thumbnailPath: output }); await this.personRepository.update({ id: personId, thumbnailPath: output });
} catch (error: Error | any) {
this.logger.error(`Failed to crop face for asset: ${assetId}, person: ${personId} - ${error}`, error.stack); return true;
}
} }
} }

View File

@ -19,9 +19,6 @@ export enum JobCommand {
} }
export enum JobName { export enum JobName {
// upload
ASSET_UPLOADED = 'asset-uploaded',
// conversion // conversion
QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', QUEUE_VIDEO_CONVERSION = 'queue-video-conversion',
VIDEO_CONVERSION = 'video-conversion', VIDEO_CONVERSION = 'video-conversion',
@ -33,8 +30,7 @@ export enum JobName {
// metadata // metadata
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
EXIF_EXTRACTION = 'exif-extraction', METADATA_EXTRACTION = 'metadata-extraction',
EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
// user deletion // user deletion
USER_DELETION = 'user-deletion', USER_DELETION = 'user-deletion',
@ -84,7 +80,6 @@ export const JOBS_ASSET_PAGINATION_SIZE = 1000;
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = { export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// misc // misc
[JobName.ASSET_UPLOADED]: QueueName.BACKGROUND_TASK,
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK, [JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK, [JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK, [JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
@ -101,8 +96,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// metadata // metadata
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.EXIF_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.EXTRACT_VIDEO_METADATA]: QueueName.METADATA_EXTRACTION,
// storage template // storage template
[JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION, [JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION,

View File

@ -1,18 +1,9 @@
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
import { BoundingBox } from '../smart-info'; import { BoundingBox } from '../smart-info';
export interface IBaseJob { export interface IBaseJob {
force?: boolean; force?: boolean;
} }
export interface IAlbumJob extends IBaseJob {
album: AlbumEntity;
}
export interface IAssetJob extends IBaseJob {
asset: AssetEntity;
}
export interface IAssetFaceJob extends IBaseJob { export interface IAssetFaceJob extends IBaseJob {
assetId: string; assetId: string;
personId: string; personId: string;
@ -26,6 +17,10 @@ export interface IFaceThumbnailJob extends IAssetFaceJob {
personId: string; personId: string;
} }
export interface IEntityJob extends IBaseJob {
id: string;
}
export interface IBulkEntityJob extends IBaseJob { export interface IBulkEntityJob extends IBaseJob {
ids: string[]; ids: string[];
} }
@ -33,7 +28,3 @@ export interface IBulkEntityJob extends IBaseJob {
export interface IDeleteFilesJob extends IBaseJob { export interface IDeleteFilesJob extends IBaseJob {
files: Array<string | null | undefined>; files: Array<string | null | undefined>;
} }
export interface IUserDeletionJob extends IBaseJob {
user: UserEntity;
}

View File

@ -1,12 +1,11 @@
import { JobName, QueueName } from './job.constants'; import { JobName, QueueName } from './job.constants';
import { import {
IAssetFaceJob, IAssetFaceJob,
IAssetJob,
IBaseJob, IBaseJob,
IBulkEntityJob, IBulkEntityJob,
IDeleteFilesJob, IDeleteFilesJob,
IEntityJob,
IFaceThumbnailJob, IFaceThumbnailJob,
IUserDeletionJob,
} from './job.interface'; } from './job.interface';
export interface JobCounts { export interface JobCounts {
@ -24,50 +23,46 @@ export interface QueueStatus {
} }
export type JobItem = export type JobItem =
// Asset Upload
| { name: JobName.ASSET_UPLOADED; data: IAssetJob }
// Transcoding // Transcoding
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
| { name: JobName.VIDEO_CONVERSION; data: IAssetJob } | { name: JobName.VIDEO_CONVERSION; data: IEntityJob }
// Thumbnails // Thumbnails
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob } | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob } | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
// User Deletion // User Deletion
| { name: JobName.USER_DELETE_CHECK } | { name: JobName.USER_DELETE_CHECK }
| { name: JobName.USER_DELETION; data: IUserDeletionJob } | { name: JobName.USER_DELETION; data: IEntityJob }
// Storage Template // Storage Template
| { name: JobName.STORAGE_TEMPLATE_MIGRATION } | { name: JobName.STORAGE_TEMPLATE_MIGRATION }
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IAssetJob } | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob }
| { name: JobName.SYSTEM_CONFIG_CHANGE } | { name: JobName.SYSTEM_CONFIG_CHANGE }
// Metadata Extraction // Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
| { name: JobName.EXIF_EXTRACTION; data: IAssetJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetJob }
// Sidecar Scanning // Sidecar Scanning
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
| { name: JobName.SIDECAR_DISCOVERY; data: IAssetJob } | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
| { name: JobName.SIDECAR_SYNC; data: IAssetJob } | { name: JobName.SIDECAR_SYNC; data: IEntityJob }
// Object Tagging // Object Tagging
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob } | { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
| { name: JobName.DETECT_OBJECTS; data: IAssetJob } | { name: JobName.DETECT_OBJECTS; data: IEntityJob }
| { name: JobName.CLASSIFY_IMAGE; data: IAssetJob } | { name: JobName.CLASSIFY_IMAGE; data: IEntityJob }
// Recognize Faces // Recognize Faces
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob } | { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
| { name: JobName.RECOGNIZE_FACES; data: IAssetJob } | { name: JobName.RECOGNIZE_FACES; data: IEntityJob }
| { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob } | { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob }
// Clip Embedding // Clip Embedding
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob } | { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
| { name: JobName.ENCODE_CLIP; data: IAssetJob } | { name: JobName.ENCODE_CLIP; data: IEntityJob }
// Filesystem // Filesystem
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob } | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }

View File

@ -1,14 +1,20 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { newJobRepositoryMock } from '../../test'; import { newAssetRepositoryMock, newCommunicationRepositoryMock, newJobRepositoryMock } from '../../test';
import { IAssetRepository } from '../asset';
import { ICommunicationRepository } from '../communication';
import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job'; import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job';
describe(JobService.name, () => { describe(JobService.name, () => {
let sut: JobService; let sut: JobService;
let assetMock: jest.Mocked<IAssetRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
beforeEach(async () => { beforeEach(async () => {
assetMock = newAssetRepositoryMock();
communicationMock = newCommunicationRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
sut = new JobService(jobMock); sut = new JobService(assetMock, communicationMock, jobMock);
}); });
it('should work', () => { it('should work', () => {

View File

@ -1,21 +1,21 @@
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { IAssetRepository, mapAsset } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { assertMachineLearningEnabled } from '../domain.constant'; import { assertMachineLearningEnabled } from '../domain.constant';
import { JobCommandDto } from './dto'; import { JobCommandDto } from './dto';
import { JobCommand, JobName, QueueName } from './job.constants'; import { JobCommand, JobName, QueueName } from './job.constants';
import { IJobRepository } from './job.repository'; import { IJobRepository, JobItem } from './job.repository';
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto'; import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
@Injectable() @Injectable()
export class JobService { export class JobService {
private logger = new Logger(JobService.name); private logger = new Logger(JobService.name);
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {} constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
async handleNightlyJobs() { @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK }); @Inject(IJobRepository) private jobRepository: IJobRepository,
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); ) {}
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
}
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> { handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
@ -89,4 +89,51 @@ export class JobService {
throw new BadRequestException(`Invalid job name: ${name}`); throw new BadRequestException(`Invalid job name: ${name}`);
} }
} }
async handleNightlyJobs() {
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
}
/**
* Queue follow up jobs
*/
async onDone(item: JobItem) {
switch (item.name) {
case JobName.SIDECAR_SYNC:
case JobName.SIDECAR_DISCOVERY:
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: item.data.id } });
break;
case JobName.METADATA_EXTRACTION:
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data });
break;
case JobName.GENERATE_JPEG_THUMBNAIL: {
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data });
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: item.data });
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });
const [asset] = await this.assetRepository.getByIds([item.data.id]);
if (asset) {
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
}
break;
}
}
// In addition to the above jobs, all of these should queue `SEARCH_INDEX_ASSET`
switch (item.name) {
case JobName.CLASSIFY_IMAGE:
case JobName.DETECT_OBJECTS:
case JobName.ENCODE_CLIP:
case JobName.RECOGNIZE_FACES:
case JobName.METADATA_EXTRACTION:
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [item.data.id] } });
break;
}
}
} }

View File

@ -1,9 +1,7 @@
import { AssetType, SystemConfigKey } from '@app/infra/entities'; import { AssetType, SystemConfigKey } from '@app/infra/entities';
import _ from 'lodash';
import { import {
assetEntityStub, assetEntityStub,
newAssetRepositoryMock, newAssetRepositoryMock,
newCommunicationRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newMediaRepositoryMock, newMediaRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
@ -11,7 +9,6 @@ import {
probeStub, probeStub,
} from '../../test'; } from '../../test';
import { IAssetRepository, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
import { ICommunicationRepository } from '../communication';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage'; import { IStorageRepository } from '../storage';
import { ISystemConfigRepository } from '../system-config'; import { ISystemConfigRepository } from '../system-config';
@ -22,7 +19,6 @@ describe(MediaService.name, () => {
let sut: MediaService; let sut: MediaService;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let mediaMock: jest.Mocked<IMediaRepository>; let mediaMock: jest.Mocked<IMediaRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
@ -30,12 +26,11 @@ describe(MediaService.name, () => {
beforeEach(async () => { beforeEach(async () => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
communicationMock = newCommunicationRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock(); mediaMock = newMediaRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock, configMock); sut = new MediaService(assetMock, jobMock, mediaMock, storageMock, configMock);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -55,7 +50,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_JPEG_THUMBNAIL, name: JobName.GENERATE_JPEG_THUMBNAIL,
data: { asset: assetEntityStub.image }, data: { id: assetEntityStub.image.id },
}); });
}); });
@ -71,23 +66,15 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_JPEG_THUMBNAIL, name: JobName.GENERATE_JPEG_THUMBNAIL,
data: { asset: assetEntityStub.image }, data: { id: assetEntityStub.image.id },
}); });
}); });
it('should log an error', async () => {
assetMock.getAll.mockRejectedValue(new Error('database unavailable'));
await sut.handleQueueGenerateThumbnails({ force: true });
expect(assetMock.getAll).toHaveBeenCalled();
});
}); });
describe('handleGenerateJpegThumbnail', () => { describe('handleGenerateJpegThumbnail', () => {
it('should generate a thumbnail for an image', async () => { it('should generate a thumbnail for an image', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) }); await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
@ -105,7 +92,7 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
mediaMock.resize.mockRejectedValue(new Error('unsupported format')); mediaMock.resize.mockRejectedValue(new Error('unsupported format'));
await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) }); await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
@ -124,7 +111,7 @@ describe(MediaService.name, () => {
it('should generate a thumbnail for a video', async () => { it('should generate a thumbnail for a video', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.video) }); await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.video.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith( expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith(
@ -138,37 +125,22 @@ describe(MediaService.name, () => {
}); });
}); });
it('should queue some jobs', async () => { it('should run successfully', async () => {
const asset = _.cloneDeep(assetEntityStub.image);
assetMock.getByIds.mockResolvedValue([asset]);
await sut.handleGenerateJpegThumbnail({ asset });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.DETECT_OBJECTS, data: { asset } });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset } });
});
it('should log an error', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
mediaMock.resize.mockRejectedValue(new Error('unsupported format')); await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
mediaMock.extractThumbnailFromExif.mockRejectedValue(new Error('unsupported format'));
await sut.handleGenerateJpegThumbnail({ asset: assetEntityStub.image });
expect(assetMock.save).not.toHaveBeenCalled();
}); });
}); });
describe('handleGenerateWebpThumbnail', () => { describe('handleGenerateWebpThumbnail', () => {
it('should skip thumbnail generate if resize path is missing', async () => { it('should skip thumbnail generate if resize path is missing', async () => {
await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.noResizePath }); assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id });
expect(mediaMock.resize).not.toHaveBeenCalled(); expect(mediaMock.resize).not.toHaveBeenCalled();
}); });
it('should generate a thumbnail', async () => { it('should generate a thumbnail', async () => {
await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.image }); assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id });
expect(mediaMock.resize).toHaveBeenCalledWith( expect(mediaMock.resize).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.ext', '/uploads/user-id/thumbs/path.ext',
@ -177,14 +149,6 @@ describe(MediaService.name, () => {
); );
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' }); expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' });
}); });
it('should log an error', async () => {
mediaMock.resize.mockRejectedValue(new Error('service unavailable'));
await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.image });
expect(mediaMock.resize).toHaveBeenCalled();
});
}); });
describe('handleQueueVideoConversion', () => { describe('handleQueueVideoConversion', () => {
@ -200,7 +164,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION, name: JobName.VIDEO_CONVERSION,
data: { asset: assetEntityStub.video }, data: { id: assetEntityStub.video.id },
}); });
}); });
@ -216,17 +180,9 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION, name: JobName.VIDEO_CONVERSION,
data: { asset: assetEntityStub.video }, data: { id: assetEntityStub.video.id },
}); });
}); });
it('should log an error', async () => {
assetMock.getAll.mockRejectedValue(new Error('database unavailable'));
await sut.handleQueueVideoConversion({ force: true });
expect(assetMock.getAll).toHaveBeenCalled();
});
}); });
describe('handleVideoConversion', () => { describe('handleVideoConversion', () => {
@ -234,18 +190,11 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
}); });
it('should log an error', async () => {
mediaMock.transcode.mockRejectedValue(new Error('unable to transcode'));
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(storageMock.mkdirSync).toHaveBeenCalled();
});
it('should transcode the longest stream', async () => { it('should transcode the longest stream', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
expect(configMock.load).toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled();
@ -262,20 +211,23 @@ describe(MediaService.name, () => {
it('should skip a video without any streams', async () => { it('should skip a video without any streams', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });
it('should skip a video without any height', async () => { it('should skip a video without any height', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noHeight); mediaMock.probe.mockResolvedValue(probeStub.noHeight);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });
it('should transcode when set to all', async () => { it('should transcode when set to all', async () => {
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -289,7 +241,7 @@ describe(MediaService.name, () => {
it('should transcode when optimal and too big', async () => { it('should transcode when optimal and too big', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -310,7 +262,8 @@ describe(MediaService.name, () => {
it('should transcode with alternate scaling video is vertical', async () => { it('should transcode with alternate scaling video is vertical', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -331,7 +284,8 @@ describe(MediaService.name, () => {
it('should transcode when audio doesnt match target', async () => { it('should transcode when audio doesnt match target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3); mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -352,7 +306,8 @@ describe(MediaService.name, () => {
it('should transcode when container doesnt match target', async () => { it('should transcode when container doesnt match target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -373,14 +328,16 @@ describe(MediaService.name, () => {
it('should not transcode an invalid transcode value', async () => { it('should not transcode an invalid transcode value', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });
it('should set max bitrate if above 0', async () => { it('should set max bitrate if above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -405,7 +362,8 @@ describe(MediaService.name, () => {
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
]); ]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -428,7 +386,8 @@ describe(MediaService.name, () => {
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -452,7 +411,8 @@ describe(MediaService.name, () => {
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, { key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
]); ]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -479,7 +439,8 @@ describe(MediaService.name, () => {
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, { key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
]); ]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',
@ -503,7 +464,8 @@ describe(MediaService.name, () => {
it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => { it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video }); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/asset-id.mp4',

View File

@ -1,10 +1,9 @@
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities'; import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { join } from 'path'; import { join } from 'path';
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IAssetJob, IBaseJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config'; import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core'; import { SystemConfigCore } from '../system-config/system-config.core';
@ -19,7 +18,6 @@ export class MediaService {
constructor( constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@ -28,8 +26,7 @@ export class MediaService {
this.configCore = new SystemConfigCore(systemConfig); this.configCore = new SystemConfigCore(systemConfig);
} }
async handleQueueGenerateThumbnails(job: IBaseJob): Promise<void> { async handleQueueGenerateThumbnails(job: IBaseJob) {
try {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
@ -40,25 +37,19 @@ export class MediaService {
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
}
}
} catch (error: any) {
this.logger.error('Failed to queue generate thumbnail jobs', error.stack);
} }
} }
async handleGenerateJpegThumbnail(data: IAssetJob): Promise<void> { return true;
const [asset] = await this.assetRepository.getByIds([data.asset.id]); }
async handleGenerateJpegThumbnail({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
this.logger.warn( return false;
`Asset not found: ${data.asset.id} - Original Path: ${data.asset.originalPath} - Resize Path: ${data.asset.resizePath}`,
);
return;
} }
try {
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId); const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
this.storageRepository.mkdirSync(resizePath); this.storageRepository.mkdirSync(resizePath);
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
@ -85,41 +76,26 @@ export class MediaService {
await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath }); await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
asset.resizePath = jpegThumbnailPath; return true;
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } });
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
} catch (error: any) {
this.logger.error(`Failed to generate thumbnail for asset: ${asset.id}/${asset.type}`, error.stack);
}
} }
async handleGenerateWepbThumbnail(data: IAssetJob): Promise<void> { async handleGenerateWepbThumbnail({ id }: IEntityJob) {
const { asset } = data; const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || !asset.resizePath) {
if (!asset.resizePath) { return false;
return;
} }
const webpPath = asset.resizePath.replace('jpeg', 'webp'); const webpPath = asset.resizePath.replace('jpeg', 'webp');
try {
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' }); await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
await this.assetRepository.save({ id: asset.id, webpPath: webpPath }); await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
} catch (error: any) {
this.logger.error(`Failed to generate webp thumbnail for asset: ${asset.id}`, error.stack); return true;
}
} }
async handleQueueVideoConversion(job: IBaseJob) { async handleQueueVideoConversion(job: IBaseJob) {
const { force } = job; const { force } = job;
try {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO }) ? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
@ -128,23 +104,19 @@ export class MediaService {
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } }); await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
}
}
} catch (error: any) {
this.logger.error('Failed to queue video conversions', error.stack);
} }
} }
async handleVideoConversion(job: IAssetJob) { return true;
const [asset] = await this.assetRepository.getByIds([job.asset.id]); }
async handleVideoConversion({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
this.logger.warn(`Asset not found: ${job.asset.id} - Original Path: ${job.asset.originalPath}`); return false;
return;
} }
try {
const input = asset.originalPath; const input = asset.originalPath;
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId); const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
const output = join(outputFolder, `${asset.id}.mp4`); const output = join(outputFolder, `${asset.id}.mp4`);
@ -155,14 +127,14 @@ export class MediaService {
const mainAudioStream = this.getMainAudioStream(audioStreams); const mainAudioStream = this.getMainAudioStream(audioStreams);
const containerExtension = format.formatName; const containerExtension = format.formatName;
if (!mainVideoStream || !mainAudioStream || !containerExtension) { if (!mainVideoStream || !mainAudioStream || !containerExtension) {
return; return false;
} }
const { ffmpeg: config } = await this.configCore.getConfig(); const { ffmpeg: config } = await this.configCore.getConfig();
const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config); const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config);
if (!required) { if (!required) {
return; return false;
} }
const outputOptions = this.getFfmpegOptions(mainVideoStream, config); const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
@ -174,9 +146,8 @@ export class MediaService {
this.logger.log(`Encoding success ${asset.id}`); this.logger.log(`Encoding success ${asset.id}`);
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output }); await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
} catch (error: any) {
this.logger.error(`Failed to handle video conversion for asset: ${asset.id}`, error.stack); return true;
}
} }
private getMainVideoStream(streams: VideoStreamInfo[]): VideoStreamInfo | null { private getMainVideoStream(streams: VideoStreamInfo[]): VideoStreamInfo | null {

View File

@ -33,7 +33,7 @@ describe(MetadataService.name, () => {
expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SIDECAR_SYNC, name: JobName.SIDECAR_SYNC,
data: { asset: assetEntityStub.sidecar }, data: { id: assetEntityStub.sidecar.id },
}); });
}); });
@ -46,95 +46,59 @@ describe(MetadataService.name, () => {
expect(assetMock.getWith).not.toHaveBeenCalled(); expect(assetMock.getWith).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SIDECAR_DISCOVERY, name: JobName.SIDECAR_DISCOVERY,
data: { asset: assetEntityStub.image }, data: { id: assetEntityStub.image.id },
}); });
}); });
it('should log an error', async () => {
assetMock.getWith.mockRejectedValue(new Error('database unavailable'));
await sut.handleQueueSidecar({ force: true });
expect(jobMock.queue).not.toHaveBeenCalled();
});
}); });
describe('handleSidecarSync', () => { describe('handleSidecarSync', () => {
it('should skip hidden assets', async () => { it('should not error', async () => {
await sut.handleSidecarSync({ asset: assetEntityStub.livePhotoMotionAsset }); await sut.handleSidecarSync();
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should handle video assets', async () => {
await sut.handleSidecarSync({ asset: assetEntityStub.video });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.EXTRACT_VIDEO_METADATA,
data: { asset: assetEntityStub.video },
});
});
it('should handle image assets', async () => {
await sut.handleSidecarSync({ asset: assetEntityStub.image });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.EXIF_EXTRACTION,
data: { asset: assetEntityStub.image },
});
});
it('should log an error', async () => {
jobMock.queue.mockRejectedValue(new Error('queue job failed'));
await sut.handleSidecarSync({ asset: assetEntityStub.image });
}); });
}); });
describe('handleSidecarDiscovery', () => { describe('handleSidecarDiscovery', () => {
it('should skip hidden assets', async () => { it('should skip hidden assets', async () => {
await sut.handleSidecarDiscovery({ asset: assetEntityStub.livePhotoMotionAsset }); assetMock.getByIds.mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
await sut.handleSidecarDiscovery({ id: assetEntityStub.livePhotoMotionAsset.id });
expect(storageMock.checkFileExists).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled();
}); });
it('should skip assets with a sidecar path', async () => { it('should skip assets with a sidecar path', async () => {
await sut.handleSidecarDiscovery({ asset: assetEntityStub.sidecar }); assetMock.getByIds.mockResolvedValue([assetEntityStub.sidecar]);
await sut.handleSidecarDiscovery({ id: assetEntityStub.sidecar.id });
expect(storageMock.checkFileExists).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled();
}); });
it('should do nothing when a sidecar is not found ', async () => { it('should do nothing when a sidecar is not found ', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
storageMock.checkFileExists.mockResolvedValue(false); storageMock.checkFileExists.mockResolvedValue(false);
await sut.handleSidecarDiscovery({ asset: assetEntityStub.image }); await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
}); });
it('should update a image asset when a sidecar is found', async () => { it('should update a image asset when a sidecar is found', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
assetMock.save.mockResolvedValue(assetEntityStub.image); assetMock.save.mockResolvedValue(assetEntityStub.image);
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
await sut.handleSidecarDiscovery({ asset: assetEntityStub.image }); await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK); expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id, id: assetEntityStub.image.id,
sidecarPath: '/original/path.ext.xmp', sidecarPath: '/original/path.ext.xmp',
}); });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.EXIF_EXTRACTION,
data: { asset: assetEntityStub.image },
});
}); });
it('should update a video asset when a sidecar is found', async () => { it('should update a video asset when a sidecar is found', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
assetMock.save.mockResolvedValue(assetEntityStub.video); assetMock.save.mockResolvedValue(assetEntityStub.video);
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
await sut.handleSidecarDiscovery({ asset: assetEntityStub.video }); await sut.handleSidecarDiscovery({ id: assetEntityStub.video.id });
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK); expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({
id: assetEntityStub.image.id, id: assetEntityStub.image.id,
sidecarPath: '/original/path.ext.xmp', sidecarPath: '/original/path.ext.xmp',
}); });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.EXTRACT_VIDEO_METADATA,
data: { asset: assetEntityStub.video },
});
});
it('should log an error', async () => {
storageMock.checkFileExists.mockRejectedValue(new Error('bad permission'));
await sut.handleSidecarDiscovery({ asset: assetEntityStub.image });
}); });
}); });
}); });

View File

@ -1,25 +1,18 @@
import { AssetType } from '@app/infra/entities'; import { Inject } from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import { constants } from 'fs/promises'; import { constants } from 'fs/promises';
import { AssetCore, IAssetRepository, WithoutProperty, WithProperty } from '../asset'; import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IAssetJob, IBaseJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { IStorageRepository } from '../storage'; import { IStorageRepository } from '../storage';
export class MetadataService { export class MetadataService {
private logger = new Logger(MetadataService.name);
private assetCore: AssetCore;
constructor( constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {}
this.assetCore = new AssetCore(assetRepository, jobRepository);
}
async handleQueueSidecar(job: IBaseJob) { async handleQueueSidecar(job: IBaseJob) {
try {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
@ -30,48 +23,32 @@ export class MetadataService {
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY; const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
await this.jobRepository.queue({ name, data: { asset } }); await this.jobRepository.queue({ name, data: { id: asset.id } });
}
}
} catch (error: any) {
this.logger.error(`Unable to queue sidecar scanning`, error?.stack);
} }
} }
async handleSidecarSync(job: IAssetJob) { return true;
const { asset } = job;
if (!asset.isVisible) {
return;
} }
try { async handleSidecarSync() {
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION; // TODO: optimize to only queue assets with recent xmp changes
await this.jobRepository.queue({ name, data: { asset } }); return true;
} catch (error: any) {
this.logger.error(`Unable to queue metadata extraction`, error?.stack);
}
} }
async handleSidecarDiscovery(job: IAssetJob) { async handleSidecarDiscovery({ id }: IEntityJob) {
let { asset } = job; const [asset] = await this.assetRepository.getByIds([id]);
if (!asset.isVisible || asset.sidecarPath) { if (!asset || !asset.isVisible || asset.sidecarPath) {
return; return false;
} }
try {
const sidecarPath = `${asset.originalPath}.xmp`; const sidecarPath = `${asset.originalPath}.xmp`;
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.W_OK); const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.W_OK);
if (!exists) { if (!exists) {
return; return false;
} }
asset = await this.assetCore.save({ id: asset.id, sidecarPath }); await this.assetRepository.save({ id: asset.id, sidecarPath });
// TODO: optimize to only queue assets with recent xmp changes
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION; return true;
await this.jobRepository.queue({ name, data: { asset } });
} catch (error: any) {
this.logger.error(`Unable to queue metadata extraction: ${error}`, error?.stack);
return;
}
} }
} }

View File

@ -122,14 +122,5 @@ describe(PersonService.name, () => {
data: { files: ['/path/to/thumbnail'] }, data: { files: ['/path/to/thumbnail'] },
}); });
}); });
it('should log an error', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
personMock.delete.mockRejectedValue(new Error('database unavailable'));
await sut.handlePersonCleanup();
expect(jobMock.queue).not.toHaveBeenCalled();
});
}); });
}); });

View File

@ -67,7 +67,7 @@ export class PersonService {
return mapPerson(person); return mapPerson(person);
} }
async handlePersonCleanup(): Promise<void> { async handlePersonCleanup() {
const people = await this.repository.getAllWithoutFaces(); const people = await this.repository.getAllWithoutFaces();
for (const person of people) { for (const person of people) {
this.logger.debug(`Person ${person.name || person.id} no longer has any faces, deleting.`); this.logger.debug(`Person ${person.name || person.id} no longer has any faces, deleting.`);
@ -78,5 +78,7 @@ export class PersonService {
this.logger.error(`Unable to delete person: ${error}`, error?.stack); this.logger.error(`Unable to delete person: ${error}`, error?.stack);
} }
} }
return true;
} }
} }

View File

@ -204,18 +204,6 @@ describe(SearchService.name, () => {
]); ]);
}); });
it('should log an error', async () => {
assetMock.getAll.mockResolvedValue({
items: [assetEntityStub.image],
hasNextPage: false,
});
searchMock.importAssets.mockRejectedValue(new Error('import failed'));
await sut.handleIndexAssets();
expect(searchMock.importAssets).toHaveBeenCalled();
});
it('should skip if search is disabled', async () => { it('should skip if search is disabled', async () => {
const sut = makeSut('false'); const sut = makeSut('false');
@ -250,15 +238,6 @@ describe(SearchService.name, () => {
expect(searchMock.importAlbums).toHaveBeenCalledWith([albumStub.empty], true); expect(searchMock.importAlbums).toHaveBeenCalledWith([albumStub.empty], true);
}); });
it('should log an error', async () => {
albumMock.getAll.mockResolvedValue([albumStub.empty]);
searchMock.importAlbums.mockRejectedValue(new Error('import failed'));
await sut.handleIndexAlbums();
expect(searchMock.importAlbums).toHaveBeenCalled();
});
}); });
describe('handleIndexAlbum', () => { describe('handleIndexAlbum', () => {
@ -325,15 +304,6 @@ describe(SearchService.name, () => {
]); ]);
}); });
it('should log an error', async () => {
faceMock.getAll.mockResolvedValue([faceStub.face1]);
searchMock.importFaces.mockRejectedValue(new Error('import failed'));
await sut.handleIndexFaces();
expect(searchMock.importFaces).toHaveBeenCalled();
});
it('should skip if search is disabled', async () => { it('should skip if search is disabled', async () => {
const sut = makeSut('false'); const sut = makeSut('false');

View File

@ -137,24 +137,21 @@ export class SearchService {
async handleIndexAlbums() { async handleIndexAlbums() {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
try {
const albums = this.patchAlbums(await this.albumRepository.getAll()); const albums = this.patchAlbums(await this.albumRepository.getAll());
this.logger.log(`Indexing ${albums.length} albums`); this.logger.log(`Indexing ${albums.length} albums`);
await this.searchRepository.importAlbums(albums, true); await this.searchRepository.importAlbums(albums, true);
} catch (error: any) {
this.logger.error(`Unable to index all albums`, error?.stack); return true;
}
} }
async handleIndexAssets() { async handleIndexAssets() {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
try {
// TODO: do this in batches based on searchIndexVersion // TODO: do this in batches based on searchIndexVersion
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination, { isVisible: true }), this.assetRepository.getAll(pagination, { isVisible: true }),
@ -170,17 +167,15 @@ export class SearchService {
await this.searchRepository.importAssets([], true); await this.searchRepository.importAssets([], true);
this.logger.debug('Finished re-indexing all assets'); this.logger.debug('Finished re-indexing all assets');
} catch (error: any) {
this.logger.error(`Unable to index all assets`, error?.stack); return false;
}
} }
async handleIndexFaces() { async handleIndexFaces() {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
try {
// TODO: do this in batches based on searchIndexVersion // TODO: do this in batches based on searchIndexVersion
const faces = this.patchFaces(await this.faceRepository.getAll()); const faces = this.patchFaces(await this.faceRepository.getAll());
this.logger.log(`Indexing ${faces.length} faces`); this.logger.log(`Indexing ${faces.length} faces`);
@ -193,66 +188,77 @@ export class SearchService {
await this.searchRepository.importFaces([], true); await this.searchRepository.importFaces([], true);
this.logger.debug('Finished re-indexing all faces'); this.logger.debug('Finished re-indexing all faces');
} catch (error: any) {
this.logger.error(`Unable to index all faces`, error?.stack); return true;
}
} }
handleIndexAlbum({ ids }: IBulkEntityJob) { handleIndexAlbum({ ids }: IBulkEntityJob) {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
for (const id of ids) { for (const id of ids) {
this.albumQueue.upsert.add(id); this.albumQueue.upsert.add(id);
} }
return true;
} }
handleIndexAsset({ ids }: IBulkEntityJob) { handleIndexAsset({ ids }: IBulkEntityJob) {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
for (const id of ids) { for (const id of ids) {
this.assetQueue.upsert.add(id); this.assetQueue.upsert.add(id);
} }
return true;
} }
async handleIndexFace({ assetId, personId }: IAssetFaceJob) { async handleIndexFace({ assetId, personId }: IAssetFaceJob) {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
// immediately push to typesense // immediately push to typesense
await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false); await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false);
return true;
} }
handleRemoveAlbum({ ids }: IBulkEntityJob) { handleRemoveAlbum({ ids }: IBulkEntityJob) {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
for (const id of ids) { for (const id of ids) {
this.albumQueue.delete.add(id); this.albumQueue.delete.add(id);
} }
return true;
} }
handleRemoveAsset({ ids }: IBulkEntityJob) { handleRemoveAsset({ ids }: IBulkEntityJob) {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
for (const id of ids) { for (const id of ids) {
this.assetQueue.delete.add(id); this.assetQueue.delete.add(id);
} }
return true;
} }
handleRemoveFace({ assetId, personId }: IAssetFaceJob) { handleRemoveFace({ assetId, personId }: IAssetFaceJob) {
if (!this.enabled) { if (!this.enabled) {
return; return false;
} }
this.faceQueue.delete.add(this.asKey({ assetId, personId })); this.faceQueue.delete.add(this.asKey({ assetId, personId }));
return true;
} }
private async flush() { private async flush() {

View File

@ -30,6 +30,8 @@ describe(SmartInfoService.name, () => {
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
machineMock = newMachineLearningRepositoryMock(); machineMock = newMachineLearningRepositoryMock();
sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock); sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock);
assetMock.getByIds.mockResolvedValue([asset]);
}); });
it('should work', () => { it('should work', () => {
@ -46,8 +48,8 @@ describe(SmartInfoService.name, () => {
await sut.handleQueueObjectTagging({ force: false }); await sut.handleQueueObjectTagging({ force: false });
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }], [{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }], [{ name: JobName.DETECT_OBJECTS, data: { id: assetEntityStub.image.id } }],
]); ]);
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.OBJECT_TAGS); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.OBJECT_TAGS);
}); });
@ -61,8 +63,8 @@ describe(SmartInfoService.name, () => {
await sut.handleQueueObjectTagging({ force: true }); await sut.handleQueueObjectTagging({ force: true });
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }], [{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }], [{ name: JobName.DETECT_OBJECTS, data: { id: assetEntityStub.image.id } }],
]); ]);
expect(assetMock.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
}); });
@ -70,7 +72,10 @@ describe(SmartInfoService.name, () => {
describe('handleTagImage', () => { describe('handleTagImage', () => {
it('should skip assets without a resize path', async () => { it('should skip assets without a resize path', async () => {
await sut.handleClassifyImage({ asset: { resizePath: '' } as AssetEntity }); const asset = { resizePath: '' } as AssetEntity;
assetMock.getByIds.mockResolvedValue([asset]);
await sut.handleClassifyImage({ id: asset.id });
expect(smartMock.upsert).not.toHaveBeenCalled(); expect(smartMock.upsert).not.toHaveBeenCalled();
expect(machineMock.classifyImage).not.toHaveBeenCalled(); expect(machineMock.classifyImage).not.toHaveBeenCalled();
@ -79,7 +84,7 @@ describe(SmartInfoService.name, () => {
it('should save the returned tags', async () => { it('should save the returned tags', async () => {
machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']); machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
await sut.handleClassifyImage({ asset }); await sut.handleClassifyImage({ id: asset.id });
expect(machineMock.classifyImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); expect(machineMock.classifyImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
expect(smartMock.upsert).toHaveBeenCalledWith({ expect(smartMock.upsert).toHaveBeenCalledWith({
@ -88,18 +93,10 @@ describe(SmartInfoService.name, () => {
}); });
}); });
it('should handle an error with the machine learning pipeline', async () => {
machineMock.classifyImage.mockRejectedValue(new Error('Unable to read thumbnail'));
await sut.handleClassifyImage({ asset });
expect(smartMock.upsert).not.toHaveBeenCalled();
});
it('should no update the smart info if no tags were returned', async () => { it('should no update the smart info if no tags were returned', async () => {
machineMock.classifyImage.mockResolvedValue([]); machineMock.classifyImage.mockResolvedValue([]);
await sut.handleClassifyImage({ asset }); await sut.handleClassifyImage({ id: asset.id });
expect(machineMock.classifyImage).toHaveBeenCalled(); expect(machineMock.classifyImage).toHaveBeenCalled();
expect(smartMock.upsert).not.toHaveBeenCalled(); expect(smartMock.upsert).not.toHaveBeenCalled();
@ -108,7 +105,10 @@ describe(SmartInfoService.name, () => {
describe('handleDetectObjects', () => { describe('handleDetectObjects', () => {
it('should skip assets without a resize path', async () => { it('should skip assets without a resize path', async () => {
await sut.handleDetectObjects({ asset: { resizePath: '' } as AssetEntity }); const asset = { resizePath: '' } as AssetEntity;
assetMock.getByIds.mockResolvedValue([asset]);
await sut.handleDetectObjects({ id: asset.id });
expect(smartMock.upsert).not.toHaveBeenCalled(); expect(smartMock.upsert).not.toHaveBeenCalled();
expect(machineMock.detectObjects).not.toHaveBeenCalled(); expect(machineMock.detectObjects).not.toHaveBeenCalled();
@ -117,7 +117,7 @@ describe(SmartInfoService.name, () => {
it('should save the returned objects', async () => { it('should save the returned objects', async () => {
machineMock.detectObjects.mockResolvedValue(['obj1', 'obj2', 'obj3']); machineMock.detectObjects.mockResolvedValue(['obj1', 'obj2', 'obj3']);
await sut.handleDetectObjects({ asset }); await sut.handleDetectObjects({ id: asset.id });
expect(machineMock.detectObjects).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); expect(machineMock.detectObjects).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
expect(smartMock.upsert).toHaveBeenCalledWith({ expect(smartMock.upsert).toHaveBeenCalledWith({
@ -126,18 +126,10 @@ describe(SmartInfoService.name, () => {
}); });
}); });
it('should handle an error with the machine learning pipeline', async () => {
machineMock.detectObjects.mockRejectedValue(new Error('Unable to read thumbnail'));
await sut.handleDetectObjects({ asset });
expect(smartMock.upsert).not.toHaveBeenCalled();
});
it('should no update the smart info if no objects were returned', async () => { it('should no update the smart info if no objects were returned', async () => {
machineMock.detectObjects.mockResolvedValue([]); machineMock.detectObjects.mockResolvedValue([]);
await sut.handleDetectObjects({ asset }); await sut.handleDetectObjects({ id: asset.id });
expect(machineMock.detectObjects).toHaveBeenCalled(); expect(machineMock.detectObjects).toHaveBeenCalled();
expect(smartMock.upsert).not.toHaveBeenCalled(); expect(smartMock.upsert).not.toHaveBeenCalled();
@ -153,7 +145,7 @@ describe(SmartInfoService.name, () => {
await sut.handleQueueEncodeClip({ force: false }); await sut.handleQueueEncodeClip({ force: false });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetEntityStub.image.id } });
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.CLIP_ENCODING); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.CLIP_ENCODING);
}); });
@ -165,14 +157,17 @@ describe(SmartInfoService.name, () => {
await sut.handleQueueEncodeClip({ force: true }); await sut.handleQueueEncodeClip({ force: true });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetEntityStub.image.id } });
expect(assetMock.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
}); });
}); });
describe('handleEncodeClip', () => { describe('handleEncodeClip', () => {
it('should skip assets without a resize path', async () => { it('should skip assets without a resize path', async () => {
await sut.handleEncodeClip({ asset: { resizePath: '' } as AssetEntity }); const asset = { resizePath: '' } as AssetEntity;
assetMock.getByIds.mockResolvedValue([asset]);
await sut.handleEncodeClip({ id: asset.id });
expect(smartMock.upsert).not.toHaveBeenCalled(); expect(smartMock.upsert).not.toHaveBeenCalled();
expect(machineMock.encodeImage).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled();
@ -181,7 +176,7 @@ describe(SmartInfoService.name, () => {
it('should save the returned objects', async () => { it('should save the returned objects', async () => {
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
await sut.handleEncodeClip({ asset }); await sut.handleEncodeClip({ id: asset.id });
expect(machineMock.encodeImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); expect(machineMock.encodeImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
expect(smartMock.upsert).toHaveBeenCalledWith({ expect(smartMock.upsert).toHaveBeenCalledWith({
@ -189,13 +184,5 @@ describe(SmartInfoService.name, () => {
clipEmbedding: [0.01, 0.02, 0.03], clipEmbedding: [0.01, 0.02, 0.03],
}); });
}); });
it('should handle an error with the machine learning pipeline', async () => {
machineMock.encodeImage.mockRejectedValue(new Error('Unable to read thumbnail'));
await sut.handleEncodeClip({ asset });
expect(smartMock.upsert).not.toHaveBeenCalled();
});
}); });
}); });

View File

@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { IAssetRepository, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
import { MACHINE_LEARNING_ENABLED } from '../domain.constant'; import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IAssetJob, IBaseJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { IMachineLearningRepository } from './machine-learning.interface'; import { IMachineLearningRepository } from './machine-learning.interface';
import { ISmartInfoRepository } from './smart-info.repository'; import { ISmartInfoRepository } from './smart-info.repository';
@ -18,7 +18,6 @@ export class SmartInfoService {
) {} ) {}
async handleQueueObjectTagging({ force }: IBaseJob) { async handleQueueObjectTagging({ force }: IBaseJob) {
try {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination) ? this.assetRepository.getAll(pagination)
@ -27,53 +26,49 @@ export class SmartInfoService {
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } }); await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { id: asset.id } });
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } }); await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { id: asset.id } });
}
}
} catch (error: any) {
this.logger.error(`Unable to queue object tagging`, error?.stack);
} }
} }
async handleDetectObjects(data: IAssetJob) { return true;
const { asset } = data; }
async handleDetectObjects({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return; return false;
} }
try {
const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath }); const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
if (objects.length > 0) { if (objects.length === 0) {
await this.repository.upsert({ assetId: asset.id, objects }); return false;
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
}
} catch (error: any) {
this.logger.error(`Unable run object detection pipeline: ${asset.id}`, error?.stack);
}
} }
async handleClassifyImage(data: IAssetJob) { await this.repository.upsert({ assetId: asset.id, objects });
const { asset } = data;
return true;
}
async handleClassifyImage({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return; return false;
} }
try {
const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath }); const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath });
if (tags.length > 0) { if (tags.length === 0) {
return false;
}
await this.repository.upsert({ assetId: asset.id, tags }); await this.repository.upsert({ assetId: asset.id, tags });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
} return true;
} catch (error: any) {
this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
}
} }
async handleQueueEncodeClip({ force }: IBaseJob) { async handleQueueEncodeClip({ force }: IBaseJob) {
try {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination) ? this.assetRepository.getAll(pagination)
@ -82,27 +77,23 @@ export class SmartInfoService {
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } }); await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { id: asset.id } });
}
}
} catch (error: any) {
this.logger.error(`Unable to queue clip encoding`, error?.stack);
} }
} }
async handleEncodeClip(data: IAssetJob) { return true;
const { asset } = data; }
async handleEncodeClip({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return; return false;
} }
try {
const clipEmbedding = await this.machineLearning.encodeImage({ thumbnailPath: asset.resizePath }); const clipEmbedding = await this.machineLearning.encodeImage({ thumbnailPath: asset.resizePath });
await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding }); await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
} catch (error: any) { return true;
this.logger.error(`Unable run clip encoding pipeline: ${asset.id}`, error?.stack);
}
} }
} }

View File

@ -195,11 +195,4 @@ describe(StorageTemplateService.name, () => {
]); ]);
}); });
}); });
it('should handle an error', async () => {
storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem'));
userMock.getList.mockResolvedValue([]);
await sut.handleMigration();
});
}); });

View File

@ -3,7 +3,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { IAssetRepository } from '../asset/asset.repository'; import { IAssetRepository } from '../asset/asset.repository';
import { APP_MEDIA_LOCATION } from '../domain.constant'; import { APP_MEDIA_LOCATION } from '../domain.constant';
import { getLivePhotoMotionFilename, usePagination } from '../domain.util'; import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
import { IAssetJob, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { IStorageRepository } from '../storage/storage.repository'; import { IStorageRepository } from '../storage/storage.repository';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user/user.repository'; import { IUserRepository } from '../user/user.repository';
@ -29,10 +29,9 @@ export class StorageTemplateService {
this.core = new StorageTemplateCore(configRepository, config, storageRepository); this.core = new StorageTemplateCore(configRepository, config, storageRepository);
} }
async handleMigrationSingle(data: IAssetJob) { async handleMigrationSingle({ id }: IEntityJob) {
const { asset } = data; const [asset] = await this.assetRepository.getByIds([id]);
try {
const user = await this.userRepository.get(asset.ownerId); const user = await this.userRepository.get(asset.ownerId);
const storageLabel = user?.storageLabel || null; const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id; const filename = asset.originalFileName || asset.id;
@ -44,9 +43,8 @@ export class StorageTemplateService {
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
} }
} catch (error: any) {
this.logger.error('Error running single template migration', error); return true;
}
} }
async handleMigration() { async handleMigration() {
@ -69,11 +67,11 @@ export class StorageTemplateService {
this.logger.debug('Cleaning up empty directories...'); this.logger.debug('Cleaning up empty directories...');
await this.storageRepository.removeEmptyDirs(APP_MEDIA_LOCATION); await this.storageRepository.removeEmptyDirs(APP_MEDIA_LOCATION);
} catch (error: any) {
this.logger.error('Error running template migration', error);
} finally { } finally {
console.timeEnd('migrating-time'); console.timeEnd('migrating-time');
} }
return true;
} }
// TODO: use asset core (once in domain) // TODO: use asset core (once in domain)

View File

@ -11,6 +11,7 @@ export class StorageService {
async handleDeleteFiles(job: IDeleteFilesJob) { async handleDeleteFiles(job: IDeleteFilesJob) {
const { files } = job; const { files } = job;
// TODO: one job per file
for (const file of files) { for (const file of files) {
if (!file) { if (!file) {
continue; continue;
@ -22,5 +23,7 @@ export class StorageService {
this.logger.warn('Unable to remove file from disk', error); this.logger.warn('Unable to remove file from disk', error);
} }
} }
return true;
} }
} }

View File

@ -46,6 +46,7 @@ export class SystemConfigService {
async refreshConfig() { async refreshConfig() {
await this.core.refreshConfig(); await this.core.refreshConfig();
return true;
} }
addValidator(validator: SystemConfigValidator) { addValidator(validator: SystemConfigValidator) {

View File

@ -455,21 +455,22 @@ describe(UserService.name, () => {
}); });
it('should queue user ready for deletion', async () => { it('should queue user ready for deletion', async () => {
const user = { deletedAt: makeDeletedAt(10) }; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) };
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
await sut.handleUserDeleteCheck(); await sut.handleUserDeleteCheck();
expect(userMock.getDeletedUsers).toHaveBeenCalled(); expect(userMock.getDeletedUsers).toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { user } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { id: user.id } });
}); });
}); });
describe('handleUserDelete', () => { describe('handleUserDelete', () => {
it('should skip users not ready for deletion', async () => { it('should skip users not ready for deletion', async () => {
const user = { deletedAt: makeDeletedAt(5) } as UserEntity; const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity;
userMock.get.mockResolvedValue(user);
await sut.handleUserDelete({ user }); await sut.handleUserDelete({ id: user.id });
expect(storageMock.unlinkDir).not.toHaveBeenCalled(); expect(storageMock.unlinkDir).not.toHaveBeenCalled();
expect(userMock.delete).not.toHaveBeenCalled(); expect(userMock.delete).not.toHaveBeenCalled();
@ -477,8 +478,9 @@ describe(UserService.name, () => {
it('should delete the user and associated assets', async () => { it('should delete the user and associated assets', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
userMock.get.mockResolvedValue(user);
await sut.handleUserDelete({ user }); await sut.handleUserDelete({ id: user.id });
const options = { force: true, recursive: true }; const options = { force: true, recursive: true };
@ -494,22 +496,13 @@ describe(UserService.name, () => {
it('should delete the library path for a storage label', async () => { it('should delete the library path for a storage label', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity;
userMock.get.mockResolvedValue(user);
await sut.handleUserDelete({ user }); await sut.handleUserDelete({ id: user.id });
const options = { force: true, recursive: true }; const options = { force: true, recursive: true };
expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
}); });
it('should handle an error', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
storageMock.unlinkDir.mockRejectedValue(new Error('Read only filesystem'));
await sut.handleUserDelete({ user });
expect(userMock.delete).not.toHaveBeenCalled();
});
}); });
}); });

View File

@ -6,7 +6,7 @@ import { IAlbumRepository } from '../album/album.repository';
import { IAssetRepository } from '../asset/asset.repository'; import { IAssetRepository } from '../asset/asset.repository';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto/crypto.repository'; import { ICryptoRepository } from '../crypto/crypto.repository';
import { IJobRepository, IUserDeletionJob, JobName } from '../job'; import { IEntityJob, IJobRepository, JobName } from '../job';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
import { IStorageRepository } from '../storage/storage.repository'; import { IStorageRepository } from '../storage/storage.repository';
import { IUserRepository } from '../user/user.repository'; import { IUserRepository } from '../user/user.repository';
@ -138,23 +138,27 @@ export class UserService {
const users = await this.userRepository.getDeletedUsers(); const users = await this.userRepository.getDeletedUsers();
for (const user of users) { for (const user of users) {
if (this.isReadyForDeletion(user)) { if (this.isReadyForDeletion(user)) {
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { user } }); await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id } });
}
} }
} }
async handleUserDelete(data: IUserDeletionJob) { return true;
const { user } = data; }
async handleUserDelete({ id }: IEntityJob) {
const user = await this.userRepository.get(id, true);
if (!user) {
return false;
}
// just for extra protection here // just for extra protection here
if (!this.isReadyForDeletion(user)) { if (!this.isReadyForDeletion(user)) {
this.logger.warn(`Skipped user that was not ready for deletion: id=${user.id}`); this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
return; return false;
} }
this.logger.log(`Deleting user: ${user.id}`); this.logger.log(`Deleting user: ${user.id}`);
try {
const folders = [ const folders = [
this.storageCore.getLibraryFolder(user), this.storageCore.getLibraryFolder(user),
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id), this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
@ -173,9 +177,8 @@ export class UserService {
await this.albumRepository.deleteAll(user.id); await this.albumRepository.deleteAll(user.id);
await this.assetRepository.deleteAll(user.id); await this.assetRepository.deleteAll(user.id);
await this.userRepository.delete(user, true); await this.userRepository.delete(user, true);
} catch (error: any) {
this.logger.error(`Failed to remove user`, error, { id: user.id }); return true;
}
} }
private isReadyForDeletion(user: UserEntity): boolean { private isReadyForDeletion(user: UserEntity): boolean {

View File

@ -4,7 +4,7 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
return { return {
createReadStream: jest.fn(), createReadStream: jest.fn(),
unlink: jest.fn(), unlink: jest.fn(),
unlinkDir: jest.fn(), unlinkDir: jest.fn().mockResolvedValue(true),
removeEmptyDirs: jest.fn(), removeEmptyDirs: jest.fn(),
moveFile: jest.fn(), moveFile: jest.fn(),
checkFileExists: jest.fn(), checkFileExists: jest.fn(),

View File

@ -45,9 +45,6 @@ export class JobRepository implements IJobRepository {
private getJobOptions(item: JobItem): JobOptions | null { private getJobOptions(item: JobItem): JobOptions | null {
switch (item.name) { switch (item.name) {
case JobName.ASSET_UPLOADED:
return { jobId: item.data.asset.id };
case JobName.GENERATE_FACE_THUMBNAIL: case JobName.GENERATE_FACE_THUMBNAIL:
return { priority: 1 }; return { priority: 1 };