mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 20:29:05 +00:00
feat(server): harden move file (#4361)
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
newAssetRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMediaRepositoryMock,
|
||||
newMoveRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
IAssetRepository,
|
||||
IJobRepository,
|
||||
IMediaRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
@@ -38,6 +40,7 @@ describe(MediaService.name, () => {
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let moveMock: jest.Mocked<IMoveRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
||||
@@ -46,10 +49,11 @@ describe(MediaService.name, () => {
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
moveMock = newMoveRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock);
|
||||
sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock, moveMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { AssetEntity, AssetType, Colorspace, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
|
||||
import {
|
||||
AssetEntity,
|
||||
AssetPathType,
|
||||
AssetType,
|
||||
Colorspace,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
} from '@app/infra/entities';
|
||||
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||
@@ -7,6 +15,7 @@ import {
|
||||
IAssetRepository,
|
||||
IJobRepository,
|
||||
IMediaRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
@@ -32,9 +41,10 @@ export class MediaService {
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(configRepository);
|
||||
this.storageCore = new StorageCore(this.storageRepository);
|
||||
this.storageCore = new StorageCore(this.storageRepository, assetRepository, moveRepository, personRepository);
|
||||
}
|
||||
|
||||
async handleQueueGenerateThumbnails({ force }: IBaseJob) {
|
||||
@@ -108,29 +118,9 @@ export class MediaService {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (asset.resizePath) {
|
||||
const resizePath = this.ensureThumbnailPath(asset, 'jpeg');
|
||||
if (asset.resizePath !== resizePath) {
|
||||
await this.storageRepository.moveFile(asset.resizePath, resizePath);
|
||||
await this.assetRepository.save({ id: asset.id, resizePath });
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.webpPath) {
|
||||
const webpPath = this.ensureThumbnailPath(asset, 'webp');
|
||||
if (asset.webpPath !== webpPath) {
|
||||
await this.storageRepository.moveFile(asset.webpPath, webpPath);
|
||||
await this.assetRepository.save({ id: asset.id, webpPath });
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.encodedVideoPath) {
|
||||
const encodedVideoPath = this.ensureEncodedVideoPath(asset, 'mp4');
|
||||
if (asset.encodedVideoPath !== encodedVideoPath) {
|
||||
await this.storageRepository.moveFile(asset.encodedVideoPath, encodedVideoPath);
|
||||
await this.assetRepository.save({ id: asset.id, encodedVideoPath });
|
||||
}
|
||||
}
|
||||
await this.storageCore.moveAssetFile(asset, AssetPathType.JPEG_THUMBNAIL);
|
||||
await this.storageCore.moveAssetFile(asset, AssetPathType.WEBP_THUMBNAIL);
|
||||
await this.storageCore.moveAssetFile(asset, AssetPathType.ENCODED_VIDEO);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -146,15 +136,33 @@ export class MediaService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||
let path;
|
||||
private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||
const { thumbnail, ffmpeg } = await this.configCore.getConfig();
|
||||
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
|
||||
const path =
|
||||
format === 'jpeg' ? this.storageCore.getLargeThumbnailPath(asset) : this.storageCore.getSmallThumbnailPath(asset);
|
||||
this.storageCore.ensureFolders(path);
|
||||
|
||||
switch (asset.type) {
|
||||
case AssetType.IMAGE:
|
||||
path = await this.generateImageThumbnail(asset, format);
|
||||
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
|
||||
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
|
||||
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
|
||||
break;
|
||||
|
||||
case AssetType.VIDEO:
|
||||
path = await this.generateVideoThumbnail(asset, format);
|
||||
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
if (!mainVideoStream) {
|
||||
this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`);
|
||||
return;
|
||||
}
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
const config = { ...ffmpeg, targetResolution: size.toString() };
|
||||
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
|
||||
await this.mediaRepository.transcode(asset.originalPath, path, options);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
|
||||
}
|
||||
@@ -164,33 +172,6 @@ export class MediaService {
|
||||
return path;
|
||||
}
|
||||
|
||||
async generateImageThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||
const { thumbnail } = await this.configCore.getConfig();
|
||||
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
|
||||
const path = this.ensureThumbnailPath(asset, format);
|
||||
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
|
||||
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
|
||||
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
|
||||
return path;
|
||||
}
|
||||
|
||||
async generateVideoThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||
const { ffmpeg, thumbnail } = await this.configCore.getConfig();
|
||||
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
|
||||
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
if (!mainVideoStream) {
|
||||
this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`);
|
||||
return;
|
||||
}
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
const path = this.ensureThumbnailPath(asset, format);
|
||||
const config = { ...ffmpeg, targetResolution: size.toString() };
|
||||
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
|
||||
await this.mediaRepository.transcode(asset.originalPath, path, options);
|
||||
return path;
|
||||
}
|
||||
|
||||
async handleGenerateWebpThumbnail({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
@@ -239,7 +220,8 @@ export class MediaService {
|
||||
}
|
||||
|
||||
const input = asset.originalPath;
|
||||
const output = this.ensureEncodedVideoPath(asset, 'mp4');
|
||||
const output = this.storageCore.getEncodedVideoPath(asset);
|
||||
this.storageCore.ensureFolders(output);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
@@ -382,14 +364,6 @@ export class MediaService {
|
||||
return handler;
|
||||
}
|
||||
|
||||
ensureThumbnailPath(asset: AssetEntity, extension: string): string {
|
||||
return this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.${extension}`);
|
||||
}
|
||||
|
||||
ensureEncodedVideoPath(asset: AssetEntity, extension: string): string {
|
||||
return this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.${extension}`);
|
||||
}
|
||||
|
||||
isSRGB(asset: AssetEntity): boolean {
|
||||
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
|
||||
if (colorspace || profileDescription) {
|
||||
|
||||
Reference in New Issue
Block a user