1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 16:41:59 +00:00

chore(server): Store generated files (thumbnails, encoded video) in subdirectories (#4112)

* save thumbnails in subdirectories

* migration job, migrate assets and face thumbnails

* fix tests

* directory depth of two instead of three

* cleanup empty dirs after migration

* clean up empty dirs after migration, migrate people without assetId

* add job card for new migration job

* fix removeEmptyDirs race condition because of missing await

* cleanup empty directories after asset deletion

* move ensurePath to storage core

* rename jobs

* remove unnecessary property of IEntityJob

* use updated person getById, minor refactoring

* ensure that directory cleanup doesn't interfere with migration

* better description for job in ui

* fix remove directories when migration is done

* cleanup empty folders at start of migration

* fix: actually persist concurrency setting

* add comment explaining regex

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Daniel Dietzler 2023-09-25 17:07:21 +02:00 committed by GitHub
parent 07069c3b1e
commit 3053cbd4c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 277 additions and 100 deletions

View file

@ -307,6 +307,12 @@ export interface AllJobStatusResponseDto {
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'metadataExtraction': JobStatusDto; 'metadataExtraction': JobStatusDto;
/**
*
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'migration': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
@ -1779,6 +1785,7 @@ export const JobName = {
ClipEncoding: 'clipEncoding', ClipEncoding: 'clipEncoding',
BackgroundTask: 'backgroundTask', BackgroundTask: 'backgroundTask',
StorageTemplateMigration: 'storageTemplateMigration', StorageTemplateMigration: 'storageTemplateMigration',
Migration: 'migration',
Search: 'search', Search: 'search',
Sidecar: 'sidecar', Sidecar: 'sidecar',
Library: 'library' Library: 'library'
@ -3240,6 +3247,12 @@ export interface SystemConfigJobDto {
* @memberof SystemConfigJobDto * @memberof SystemConfigJobDto
*/ */
'metadataExtraction': JobSettingsDto; 'metadataExtraction': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'migration': JobSettingsDto;
/** /**
* *
* @type {JobSettingsDto} * @type {JobSettingsDto}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5343,6 +5343,9 @@
"metadataExtraction": { "metadataExtraction": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"migration": {
"$ref": "#/components/schemas/JobStatusDto"
},
"objectTagging": { "objectTagging": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
@ -5372,6 +5375,7 @@
"objectTagging", "objectTagging",
"clipEncoding", "clipEncoding",
"storageTemplateMigration", "storageTemplateMigration",
"migration",
"backgroundTask", "backgroundTask",
"search", "search",
"recognizeFaces", "recognizeFaces",
@ -6535,6 +6539,7 @@
"clipEncoding", "clipEncoding",
"backgroundTask", "backgroundTask",
"storageTemplateMigration", "storageTemplateMigration",
"migration",
"search", "search",
"sidecar", "sidecar",
"library" "library"
@ -7693,6 +7698,9 @@
"metadataExtraction": { "metadataExtraction": {
"$ref": "#/components/schemas/JobSettingsDto" "$ref": "#/components/schemas/JobSettingsDto"
}, },
"migration": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"objectTagging": { "objectTagging": {
"$ref": "#/components/schemas/JobSettingsDto" "$ref": "#/components/schemas/JobSettingsDto"
}, },
@ -7722,6 +7730,7 @@
"objectTagging", "objectTagging",
"clipEncoding", "clipEncoding",
"storageTemplateMigration", "storageTemplateMigration",
"migration",
"backgroundTask", "backgroundTask",
"search", "search",
"recognizeFaces", "recognizeFaces",

View file

@ -57,7 +57,7 @@ export interface UploadFile {
export class AssetService { export class AssetService {
private logger = new Logger(AssetService.name); private logger = new Logger(AssetService.name);
private access: AccessCore; private access: AccessCore;
private storageCore = new StorageCore(); private storageCore: StorageCore;
constructor( constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@ -67,6 +67,7 @@ export class AssetService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.access = new AccessCore(accessRepository); this.access = new AccessCore(accessRepository);
this.storageCore = new StorageCore(storageRepository);
} }
canUploadFile({ authUser, fieldName, file }: UploadRequest): true { canUploadFile({ authUser, fieldName, file }: UploadRequest): true {

View file

@ -307,14 +307,14 @@ describe(FacialRecognitionService.name, () => {
await sut.handleGenerateFaceThumbnail(face.middle); await sut.handleGenerateFaceThumbnail(face.middle);
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/pe/rs');
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', { expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
left: 95, left: 95,
top: 95, top: 95,
width: 110, width: 110,
height: 110, height: 110,
}); });
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', {
format: 'jpeg', format: 'jpeg',
size: 250, size: 250,
quality: 80, quality: 80,
@ -323,7 +323,7 @@ describe(FacialRecognitionService.name, () => {
expect(personMock.update).toHaveBeenCalledWith({ expect(personMock.update).toHaveBeenCalledWith({
faceAssetId: 'asset-1', faceAssetId: 'asset-1',
id: 'person-1', id: 'person-1',
thumbnailPath: 'upload/thumbs/user-id/person-1.jpeg', thumbnailPath: 'upload/thumbs/user-id/pe/rs/person-1.jpeg',
}); });
}); });
@ -338,7 +338,7 @@ describe(FacialRecognitionService.name, () => {
width: 510, width: 510,
height: 510, height: 510,
}); });
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', {
format: 'jpeg', format: 'jpeg',
size: 250, size: 250,
quality: 80, quality: 80,
@ -357,7 +357,7 @@ describe(FacialRecognitionService.name, () => {
width: 202, width: 202,
height: 202, height: 202,
}); });
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', {
format: 'jpeg', format: 'jpeg',
size: 250, size: 250,
quality: 80, quality: 80,

View file

@ -1,5 +1,4 @@
import { Inject, Logger } from '@nestjs/common'; import { Inject, Logger } from '@nestjs/common';
import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, IFaceThumbnailJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { IBaseJob, IEntityJob, IFaceThumbnailJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
@ -13,8 +12,8 @@ import { AssetFaceId, IFaceRepository } from './face.repository';
export class FacialRecognitionService { export class FacialRecognitionService {
private logger = new Logger(FacialRecognitionService.name); private logger = new Logger(FacialRecognitionService.name);
private storageCore = new StorageCore();
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private storageCore: StorageCore;
constructor( constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@ -28,6 +27,7 @@ export class FacialRecognitionService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.configCore = new SystemConfigCore(configRepository); this.configCore = new SystemConfigCore(configRepository);
this.storageCore = new StorageCore(storageRepository);
} }
async handleQueueRecognizeFaces({ force }: IBaseJob) { async handleQueueRecognizeFaces({ force }: IBaseJob) {
@ -117,6 +117,21 @@ export class FacialRecognitionService {
return true; return true;
} }
async handlePersonMigration({ id }: IEntityJob) {
const person = await this.personRepository.getById(id);
if (!person) {
return false;
}
const path = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, person.ownerId, `${id}.jpeg`);
if (person.thumbnailPath && person.thumbnailPath !== path) {
await this.storageRepository.moveFile(person.thumbnailPath, path);
await this.personRepository.update({ id, thumbnailPath: path });
}
return true;
}
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) { async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
const { machineLearning } = await this.configCore.getConfig(); const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
@ -132,9 +147,7 @@ export class FacialRecognitionService {
this.logger.verbose(`Cropping face for person: ${personId}`); this.logger.verbose(`Cropping face for person: ${personId}`);
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId); const output = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${personId}.jpeg`);
const output = join(outputFolder, `${personId}.jpeg`);
this.storageRepository.mkdirSync(outputFolder);
const { x1, y1, x2, y2 } = boundingBox; const { x1, y1, x2, y2 } = boundingBox;

View file

@ -7,6 +7,7 @@ export enum QueueName {
CLIP_ENCODING = 'clipEncoding', CLIP_ENCODING = 'clipEncoding',
BACKGROUND_TASK = 'backgroundTask', BACKGROUND_TASK = 'backgroundTask',
STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration',
MIGRATION = 'migration',
SEARCH = 'search', SEARCH = 'search',
SIDECAR = 'sidecar', SIDECAR = 'sidecar',
LIBRARY = 'library', LIBRARY = 'library',
@ -45,6 +46,11 @@ export enum JobName {
STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single',
SYSTEM_CONFIG_CHANGE = 'system-config-change', SYSTEM_CONFIG_CHANGE = 'system-config-change',
// migration
QUEUE_MIGRATION = 'queue-migration',
MIGRATE_ASSET = 'migrate-asset',
MIGRATE_PERSON = 'migrate-person',
// object tagging // object tagging
QUEUE_OBJECT_TAGGING = 'queue-object-tagging', QUEUE_OBJECT_TAGGING = 'queue-object-tagging',
CLASSIFY_IMAGE = 'classify-image', CLASSIFY_IMAGE = 'classify-image',
@ -119,6 +125,11 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: QueueName.STORAGE_TEMPLATE_MIGRATION, [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: QueueName.STORAGE_TEMPLATE_MIGRATION,
[JobName.SYSTEM_CONFIG_CHANGE]: QueueName.STORAGE_TEMPLATE_MIGRATION, [JobName.SYSTEM_CONFIG_CHANGE]: QueueName.STORAGE_TEMPLATE_MIGRATION,
// migration
[JobName.QUEUE_MIGRATION]: QueueName.MIGRATION,
[JobName.MIGRATE_ASSET]: QueueName.MIGRATION,
[JobName.MIGRATE_PERSON]: QueueName.MIGRATION,
// object tagging // object tagging
[JobName.QUEUE_OBJECT_TAGGING]: QueueName.OBJECT_TAGGING, [JobName.QUEUE_OBJECT_TAGGING]: QueueName.OBJECT_TAGGING,
[JobName.CLASSIFY_IMAGE]: QueueName.OBJECT_TAGGING, [JobName.CLASSIFY_IMAGE]: QueueName.OBJECT_TAGGING,

View file

@ -68,6 +68,9 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
@ApiProperty({ type: JobStatusDto }) @ApiProperty({ type: JobStatusDto })
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobStatusDto; [QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.MIGRATION]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto }) @ApiProperty({ type: JobStatusDto })
[QueueName.BACKGROUND_TASK]!: JobStatusDto; [QueueName.BACKGROUND_TASK]!: JobStatusDto;

View file

@ -46,6 +46,11 @@ export type JobItem =
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob } | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob }
| { name: JobName.SYSTEM_CONFIG_CHANGE; data?: IBaseJob } | { name: JobName.SYSTEM_CONFIG_CHANGE; data?: IBaseJob }
// Migration
| { name: JobName.QUEUE_MIGRATION; data?: IBaseJob }
| { name: JobName.MIGRATE_ASSET; data?: IEntityJob }
| { name: JobName.MIGRATE_PERSON; data?: IEntityJob }
// Metadata Extraction // Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
| { name: JobName.METADATA_EXTRACTION; data: IEntityJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob }

View file

@ -94,6 +94,7 @@ describe(JobService.name, () => {
[QueueName.OBJECT_TAGGING]: expectedJobStatus, [QueueName.OBJECT_TAGGING]: expectedJobStatus,
[QueueName.SEARCH]: expectedJobStatus, [QueueName.SEARCH]: expectedJobStatus,
[QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus, [QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus,
[QueueName.MIGRATION]: expectedJobStatus,
[QueueName.THUMBNAIL_GENERATION]: expectedJobStatus, [QueueName.THUMBNAIL_GENERATION]: expectedJobStatus,
[QueueName.VIDEO_CONVERSION]: expectedJobStatus, [QueueName.VIDEO_CONVERSION]: expectedJobStatus,
[QueueName.RECOGNIZE_FACES]: expectedJobStatus, [QueueName.RECOGNIZE_FACES]: expectedJobStatus,
@ -229,6 +230,7 @@ describe(JobService.name, () => {
[QueueName.SIDECAR]: { concurrency: 10 }, [QueueName.SIDECAR]: { concurrency: 10 },
[QueueName.LIBRARY]: { concurrency: 10 }, [QueueName.LIBRARY]: { concurrency: 10 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 10 }, [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 10 },
[QueueName.MIGRATION]: { concurrency: 10 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, [QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
}, },
@ -242,6 +244,7 @@ describe(JobService.name, () => {
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.STORAGE_TEMPLATE_MIGRATION, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.STORAGE_TEMPLATE_MIGRATION, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10);
}); });

View file

@ -76,6 +76,9 @@ export class JobService {
case QueueName.STORAGE_TEMPLATE_MIGRATION: case QueueName.STORAGE_TEMPLATE_MIGRATION:
return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
case QueueName.MIGRATION:
return this.jobRepository.queue({ name: JobName.QUEUE_MIGRATION });
case QueueName.OBJECT_TAGGING: case QueueName.OBJECT_TAGGING:
await this.configCore.requireFeature(FeatureFlag.TAG_IMAGE); await this.configCore.requireFeature(FeatureFlag.TAG_IMAGE);
return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } });

View file

@ -202,8 +202,8 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id }); await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.jpeg', { expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.jpeg', {
size: 1440, size: 1440,
format: 'jpeg', format: 'jpeg',
quality: 80, quality: 80,
@ -211,7 +211,7 @@ describe(MediaService.name, () => {
}); });
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id', id: 'asset-id',
resizePath: 'upload/thumbs/user-id/asset-id.jpeg', resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
}); });
}); });
@ -220,19 +220,23 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleGenerateJpegThumbnail({ id: assetStub.video.id }); await sut.handleGenerateJpegThumbnail({ id: assetStub.video.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.transcode).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', { expect(mediaMock.transcode).toHaveBeenCalledWith(
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'], '/original/path.ext',
outputOptions: [ 'upload/thumbs/user-id/as/se/asset-id.jpeg',
'-frames:v 1', {
'-v verbose', inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
'-vf scale=-2:1440:flags=lanczos+accurate_rnd+bitexact+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p', outputOptions: [
], '-frames:v 1',
twoPass: false, '-v verbose',
}); '-vf scale=-2:1440:flags=lanczos+accurate_rnd+bitexact+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p',
],
twoPass: false,
},
);
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id', id: 'asset-id',
resizePath: 'upload/thumbs/user-id/asset-id.jpeg', resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
}); });
}); });
@ -241,19 +245,23 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleGenerateJpegThumbnail({ id: assetStub.video.id }); await sut.handleGenerateJpegThumbnail({ id: assetStub.video.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.transcode).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', { expect(mediaMock.transcode).toHaveBeenCalledWith(
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'], '/original/path.ext',
outputOptions: [ 'upload/thumbs/user-id/as/se/asset-id.jpeg',
'-frames:v 1', {
'-v verbose', inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p', outputOptions: [
], '-frames:v 1',
twoPass: false, '-v verbose',
}); '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p',
],
twoPass: false,
},
);
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id', id: 'asset-id',
resizePath: 'upload/thumbs/user-id/asset-id.jpeg', resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
}); });
}); });
@ -275,13 +283,16 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id }); await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.webp', { expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.webp', {
format: 'webp', format: 'webp',
size: 250, size: 250,
quality: 80, quality: 80,
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
}); });
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: 'upload/thumbs/user-id/asset-id.webp' }); expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id',
webpPath: 'upload/thumbs/user-id/as/se/asset-id.webp',
});
}); });
}); });
@ -375,7 +386,7 @@ describe(MediaService.name, () => {
expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled();
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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -416,7 +427,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -442,7 +453,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -471,7 +482,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -498,7 +509,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -525,7 +536,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -552,7 +563,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -603,7 +614,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -635,7 +646,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -664,7 +675,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -695,7 +706,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -728,7 +739,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -760,7 +771,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -791,7 +802,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -821,7 +832,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -851,7 +862,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -881,7 +892,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -914,7 +925,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -976,7 +987,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'], inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [ outputOptions: [
@ -1014,7 +1025,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'], inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [ outputOptions: [
@ -1048,7 +1059,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'], inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [ outputOptions: [
@ -1083,7 +1094,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'], inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [ outputOptions: [
@ -1114,7 +1125,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'], inputOptions: ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'],
outputOptions: [ outputOptions: [
@ -1150,7 +1161,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'], inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [ outputOptions: [
@ -1186,7 +1197,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'], inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [ outputOptions: [
@ -1219,7 +1230,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'], inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [ outputOptions: [
@ -1263,7 +1274,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'], inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [ outputOptions: [
@ -1295,7 +1306,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'], inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [ outputOptions: [
@ -1329,7 +1340,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'], inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [ outputOptions: [
@ -1359,7 +1370,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel'], inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel'],
outputOptions: [ outputOptions: [
@ -1385,7 +1396,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD129', '-filter_hw_device accel'], inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD129', '-filter_hw_device accel'],
outputOptions: [ outputOptions: [
@ -1418,7 +1429,7 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledTimes(2); expect(mediaMock.transcode).toHaveBeenCalledTimes(2);
expect(mediaMock.transcode).toHaveBeenLastCalledWith( expect(mediaMock.transcode).toHaveBeenLastCalledWith(
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4', 'upload/encoded-video/user-id/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -1455,7 +1466,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -1482,7 +1493,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [
@ -1509,7 +1520,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.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/as/se/asset-id.mp4',
{ {
inputOptions: [], inputOptions: [],
outputOptions: [ outputOptions: [

View file

@ -1,9 +1,8 @@
import { AssetEntity, AssetType, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { AssetEntity, AssetType, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common'; import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset'; import { IAssetRepository, WithoutProperty } from '../asset';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { IBaseJob, IEntityJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
import { IPersonRepository } from '../person'; import { IPersonRepository } from '../person';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config'; import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
@ -14,8 +13,8 @@ import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIC
@Injectable() @Injectable()
export class MediaService { export class MediaService {
private logger = new Logger(MediaService.name); private logger = new Logger(MediaService.name);
private storageCore = new StorageCore();
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private storageCore: StorageCore;
constructor( constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@ -26,11 +25,10 @@ export class MediaService {
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
) { ) {
this.configCore = new SystemConfigCore(configRepository); this.configCore = new SystemConfigCore(configRepository);
this.storageCore = new StorageCore(this.storageRepository);
} }
async handleQueueGenerateThumbnails(job: IBaseJob) { async handleQueueGenerateThumbnails({ force }: IBaseJob) {
const { force } = job;
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)
@ -81,6 +79,58 @@ export class MediaService {
return true; return true;
} }
async handleQueueMigration() {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination),
);
const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION);
if (active === 1 && waiting === 0) {
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
}
for await (const assets of assetPagination) {
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } });
}
}
const people = await this.personRepository.getAll();
for (const person of people) {
await this.jobRepository.queue({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
}
return true;
}
async handleAssetMigration({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) {
return false;
}
const resizePath = this.ensureThumbnailPath(asset, 'jpeg');
const webpPath = this.ensureThumbnailPath(asset, 'webp');
const encodedVideoPath = this.ensureEncodedVideoPath(asset, 'mp4');
if (asset.resizePath && asset.resizePath !== resizePath) {
await this.storageRepository.moveFile(asset.resizePath, resizePath);
await this.assetRepository.save({ id: asset.id, resizePath });
}
if (asset.webpPath && asset.webpPath !== webpPath) {
await this.storageRepository.moveFile(asset.webpPath, webpPath);
await this.assetRepository.save({ id: asset.id, webpPath });
}
if (asset.encodedVideoPath && asset.encodedVideoPath !== encodedVideoPath) {
await this.storageRepository.moveFile(asset.encodedVideoPath, encodedVideoPath);
await this.assetRepository.save({ id: asset.id, encodedVideoPath });
}
return true;
}
async handleGenerateJpegThumbnail({ id }: IEntityJob) { async handleGenerateJpegThumbnail({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
@ -184,9 +234,7 @@ export class MediaService {
} }
const input = asset.originalPath; const input = asset.originalPath;
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId); const output = this.ensureEncodedVideoPath(asset, 'mp4');
const output = join(outputFolder, `${asset.id}.mp4`);
this.storageRepository.mkdirSync(outputFolder);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
const mainVideoStream = this.getMainStream(videoStreams); const mainVideoStream = this.getMainStream(videoStreams);
@ -330,8 +378,10 @@ export class MediaService {
} }
ensureThumbnailPath(asset: AssetEntity, extension: string): string { ensureThumbnailPath(asset: AssetEntity, extension: string): string {
const folderPath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId); return this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.${extension}`);
this.storageRepository.mkdirSync(folderPath); }
return join(folderPath, `${asset.id}.${extension}`);
ensureEncodedVideoPath(asset: AssetEntity, extension: string): string {
return this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.${extension}`);
} }
} }

View file

@ -37,10 +37,10 @@ describe(PersonService.name, () => {
beforeEach(async () => { beforeEach(async () => {
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new PersonService(accessMock, personMock, configMock, storageMock, jobMock); sut = new PersonService(accessMock, personMock, configMock, storageMock, jobMock);
}); });

View file

@ -16,8 +16,8 @@ import {
@Injectable() @Injectable()
export class ServerInfoService { export class ServerInfoService {
private storageCore = new StorageCore();
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private storageCore: StorageCore;
constructor( constructor(
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@ -25,6 +25,7 @@ export class ServerInfoService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.configCore = new SystemConfigCore(configRepository); this.configCore = new SystemConfigCore(configRepository);
this.storageCore = new StorageCore(storageRepository);
} }
async getInfo(): Promise<ServerInfoResponseDto> { async getInfo(): Promise<ServerInfoResponseDto> {

View file

@ -30,7 +30,7 @@ export interface MoveAssetMetadata {
export class StorageTemplateService { export class StorageTemplateService {
private logger = new Logger(StorageTemplateService.name); private logger = new Logger(StorageTemplateService.name);
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private storageCore = new StorageCore(); private storageCore: StorageCore;
private storageTemplate: HandlebarsTemplateDelegate<any>; private storageTemplate: HandlebarsTemplateDelegate<any>;
constructor( constructor(
@ -44,6 +44,7 @@ export class StorageTemplateService {
this.configCore = new SystemConfigCore(configRepository); this.configCore = new SystemConfigCore(configRepository);
this.configCore.addValidator((config) => this.validate(config)); this.configCore.addValidator((config) => this.validate(config));
this.configCore.config$.subscribe((config) => this.onConfig(config)); this.configCore.config$.subscribe((config) => this.onConfig(config));
this.storageCore = new StorageCore(storageRepository);
} }
async handleMigrationSingle({ id }: IEntityJob) { async handleMigrationSingle({ id }: IEntityJob) {

View file

@ -1,5 +1,6 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { APP_MEDIA_LOCATION } from '../domain.constant'; import { APP_MEDIA_LOCATION } from '../domain.constant';
import { IStorageRepository } from './storage.repository';
export enum StorageFolder { export enum StorageFolder {
ENCODED_VIDEO = 'encoded-video', ENCODED_VIDEO = 'encoded-video',
@ -10,6 +11,8 @@ export enum StorageFolder {
} }
export class StorageCore { export class StorageCore {
constructor(private repository: IStorageRepository) {}
getFolderLocation( getFolderLocation(
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS, folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
userId: string, userId: string,
@ -24,4 +27,22 @@ export class StorageCore {
getBaseFolder(folder: StorageFolder) { getBaseFolder(folder: StorageFolder) {
return join(APP_MEDIA_LOCATION, folder); return join(APP_MEDIA_LOCATION, folder);
} }
ensurePath(
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
ownerId: string,
fileName: string,
): string {
const folderPath = join(
this.getFolderLocation(folder, ownerId),
fileName.substring(0, 2),
fileName.substring(2, 4),
);
this.repository.mkdirSync(folderPath);
return join(folderPath, fileName);
}
removeEmptyDirs(folder: StorageFolder) {
return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
}
} }

View file

@ -26,7 +26,7 @@ export interface IStorageRepository {
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>; createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
unlink(filepath: string): Promise<void>; unlink(filepath: string): Promise<void>;
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
removeEmptyDirs(folder: string): Promise<void>; removeEmptyDirs(folder: string, self?: boolean): Promise<void>;
moveFile(source: string, target: string): Promise<void>; moveFile(source: string, target: string): Promise<void>;
checkFileExists(filepath: string, mode?: number): Promise<boolean>; checkFileExists(filepath: string, mode?: number): Promise<boolean>;
mkdirSync(filepath: string): void; mkdirSync(filepath: string): void;

View file

@ -6,9 +6,11 @@ import { IStorageRepository } from './storage.repository';
@Injectable() @Injectable()
export class StorageService { export class StorageService {
private logger = new Logger(StorageService.name); private logger = new Logger(StorageService.name);
private storageCore = new StorageCore(); private storageCore: StorageCore;
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {} constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {
this.storageCore = new StorageCore(storageRepository);
}
init() { init() {
const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY); const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);

View file

@ -47,6 +47,12 @@ export class SystemConfigJobDto implements Record<QueueName, JobSettingsDto> {
@Type(() => JobSettingsDto) @Type(() => JobSettingsDto)
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobSettingsDto; [QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.MIGRATION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto }) @ApiProperty({ type: JobSettingsDto })
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()

View file

@ -53,6 +53,7 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.SIDECAR]: { concurrency: 5 }, [QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.LIBRARY]: { concurrency: 1 }, [QueueName.LIBRARY]: { concurrency: 1 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, [QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
}, },

View file

@ -33,6 +33,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
[QueueName.SIDECAR]: { concurrency: 5 }, [QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.LIBRARY]: { concurrency: 1 }, [QueueName.LIBRARY]: { concurrency: 1 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, [QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
}, },

View file

@ -25,8 +25,8 @@ import { IUserRepository } from './user.repository';
@Injectable() @Injectable()
export class UserService { export class UserService {
private logger = new Logger(UserService.name); private logger = new Logger(UserService.name);
private storageCore: StorageCore;
private userCore: UserCore; private userCore: UserCore;
private storageCore = new StorageCore();
constructor( constructor(
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@ -37,6 +37,7 @@ export class UserService {
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.storageCore = new StorageCore(storageRepository);
this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository); this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository);
} }

View file

@ -43,6 +43,7 @@ export enum SystemConfigKey {
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency', JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency', JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency',
JOB_LIBRARY_CONCURRENCY = 'job.library.concurrency', JOB_LIBRARY_CONCURRENCY = 'job.library.concurrency',
JOB_MIGRATION_CONCURRENCY = 'job.migration.concurrency',
MACHINE_LEARNING_ENABLED = 'machineLearning.enabled', MACHINE_LEARNING_ENABLED = 'machineLearning.enabled',
MACHINE_LEARNING_URL = 'machineLearning.url', MACHINE_LEARNING_URL = 'machineLearning.url',

View file

@ -66,11 +66,7 @@ export class FilesystemProvider implements IStorageRepository {
await fs.rm(folder, options); await fs.rm(folder, options);
} }
async removeEmptyDirs(directory: string) { async removeEmptyDirs(directory: string, self: boolean = false) {
this._removeEmptyDirs(directory, false);
}
private async _removeEmptyDirs(directory: string, self: boolean) {
// lstat does not follow symlinks (in contrast to stat) // lstat does not follow symlinks (in contrast to stat)
const stats = await fs.lstat(directory); const stats = await fs.lstat(directory);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
@ -78,7 +74,7 @@ export class FilesystemProvider implements IStorageRepository {
} }
const files = await fs.readdir(directory); const files = await fs.readdir(directory);
await Promise.all(files.map((file) => this._removeEmptyDirs(path.join(directory, file), true))); await Promise.all(files.map((file) => this.removeEmptyDirs(path.join(directory, file), true)));
if (self) { if (self) {
const updated = await fs.readdir(directory); const updated = await fs.readdir(directory);

View file

@ -63,6 +63,9 @@ export class AppService {
[JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data), [JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(), [JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data), [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
[JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(),
[JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data),
[JobName.MIGRATE_PERSON]: (data) => this.facialRecognitionService.handlePersonMigration(data),
[JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(), [JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data), [JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data),

View file

@ -50,7 +50,7 @@ const validate = <T>(value: T): T | null => (typeof value === 'string' ? null :
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private logger = new Logger(MetadataExtractionProcessor.name); private logger = new Logger(MetadataExtractionProcessor.name);
private reverseGeocodingEnabled: boolean; private reverseGeocodingEnabled: boolean;
private storageCore = new StorageCore(); private storageCore: StorageCore;
constructor( constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@ -63,6 +63,7 @@ export class MetadataExtractionProcessor {
configService: ConfigService, configService: ConfigService,
) { ) {
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING'); this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
this.storageCore = new StorageCore(storageRepository);
} }
async init(deleteCache = false) { async init(deleteCache = false) {

View file

@ -131,6 +131,7 @@ export class ImmichApi {
[JobName.RecognizeFaces]: 'Recognize Faces', [JobName.RecognizeFaces]: 'Recognize Faces',
[JobName.VideoConversion]: 'Transcode Videos', [JobName.VideoConversion]: 'Transcode Videos',
[JobName.StorageTemplateMigration]: 'Storage Template Migration', [JobName.StorageTemplateMigration]: 'Storage Template Migration',
[JobName.Migration]: 'Migration',
[JobName.BackgroundTask]: 'Background Tasks', [JobName.BackgroundTask]: 'Background Tasks',
[JobName.Search]: 'Search', [JobName.Search]: 'Search',
[JobName.Library]: 'Library', [JobName.Library]: 'Library',

View file

@ -307,6 +307,12 @@ export interface AllJobStatusResponseDto {
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'metadataExtraction': JobStatusDto; 'metadataExtraction': JobStatusDto;
/**
*
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'migration': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
@ -1779,6 +1785,7 @@ export const JobName = {
ClipEncoding: 'clipEncoding', ClipEncoding: 'clipEncoding',
BackgroundTask: 'backgroundTask', BackgroundTask: 'backgroundTask',
StorageTemplateMigration: 'storageTemplateMigration', StorageTemplateMigration: 'storageTemplateMigration',
Migration: 'migration',
Search: 'search', Search: 'search',
Sidecar: 'sidecar', Sidecar: 'sidecar',
Library: 'library' Library: 'library'
@ -3240,6 +3247,12 @@ export interface SystemConfigJobDto {
* @memberof SystemConfigJobDto * @memberof SystemConfigJobDto
*/ */
'metadataExtraction': JobSettingsDto; 'metadataExtraction': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'migration': JobSettingsDto;
/** /**
* *
* @type {JobSettingsDto} * @type {JobSettingsDto}

View file

@ -110,6 +110,12 @@
allowForceCommand: false, allowForceCommand: false,
component: StorageMigrationDescription, component: StorageMigrationDescription,
}, },
[JobName.Migration]: {
icon: FolderMove,
title: api.getJobName(JobName.Migration),
subtitle: 'Migrate thumbnails for assets and faces to the latest folder structure',
allowForceCommand: false,
},
}; };
$: jobList = Object.entries(jobDetails) as [JobName, JobDetails][]; $: jobList = Object.entries(jobDetails) as [JobName, JobDetails][];