mirror of
https://github.com/immich-app/immich.git
synced 2024-11-28 09:33:27 +02:00
feat(server): transcoding improvements (#1370)
* feat: support isEdited flag for SettingSwitch * feat: add transcodeAll ffmpeg settings for extra transcoding control * refactor: tidy up and rename current video transcoding code + transcode everything * feat: better video transcoding with ffprobe analyses video files to see if they are already in the desired format allows admin to choose to transcode all videos regardless of the current format * fix: always serve encoded video if it exists * feat: change video codec option to a select box, limit options removed previous video codec config option as it's incompatible with new options removed mapping for encoder to codec as we now store the codec in the config * feat: add video conversion job for transcoding previously missed videos * chore: fix spelling of job messages to pluralise assets * chore: fix prettier/eslint warnings * feat: force switch targetAudioCodec default to aac to avoid iOS incompatibility * chore: lint issues after rebase
This commit is contained in:
parent
8eb82836b9
commit
4e0fe27de3
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,4 +4,5 @@
|
|||||||
.idea
|
.idea
|
||||||
|
|
||||||
docker/upload
|
docker/upload
|
||||||
|
uploads
|
||||||
coverage
|
coverage
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
BIN
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_f_fmpeg_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_f_fmpeg_dto_test.dart
generated
Binary file not shown.
@ -39,6 +39,7 @@ export interface IAssetRepository {
|
|||||||
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
||||||
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
||||||
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
|
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
|
||||||
|
getAssetWithNoEncodedVideo(): Promise<AssetEntity[]>;
|
||||||
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
|
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
|
||||||
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
|
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
|
||||||
getExistingAssets(
|
getExistingAssets(
|
||||||
@ -80,6 +81,15 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAssetWithNoEncodedVideo(): Promise<AssetEntity[]> {
|
||||||
|
return await this.assetRepository.find({
|
||||||
|
where: [
|
||||||
|
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() },
|
||||||
|
{ type: AssetType.VIDEO, encodedVideoPath: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
|
async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
|
||||||
return await this.assetRepository
|
return await this.assetRepository
|
||||||
.createQueryBuilder('asset')
|
.createQueryBuilder('asset')
|
||||||
|
@ -128,6 +128,7 @@ describe('AssetService', () => {
|
|||||||
getAssetWithNoEXIF: jest.fn(),
|
getAssetWithNoEXIF: jest.fn(),
|
||||||
getAssetWithNoThumbnail: jest.fn(),
|
getAssetWithNoThumbnail: jest.fn(),
|
||||||
getAssetWithNoSmartInfo: jest.fn(),
|
getAssetWithNoSmartInfo: jest.fn(),
|
||||||
|
getAssetWithNoEncodedVideo: jest.fn(),
|
||||||
getExistingAssets: jest.fn(),
|
getExistingAssets: jest.fn(),
|
||||||
countByIdAndUser: jest.fn(),
|
countByIdAndUser: jest.fn(),
|
||||||
};
|
};
|
||||||
|
@ -37,13 +37,13 @@ import {
|
|||||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||||
import { assetUtils, timeUtils } from '@app/common/utils';
|
import { timeUtils } from '@app/common/utils';
|
||||||
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
|
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
|
||||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { IAssetUploadedJob, IVideoTranscodeJob, QueueName, JobName } from '@app/domain';
|
import { IAssetUploadedJob, IVideoTranscodeJob, JobName, QueueName } from '@app/domain';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
import { DownloadService } from '../../modules/download/download.service';
|
import { DownloadService } from '../../modules/download/download.service';
|
||||||
@ -122,7 +122,7 @@ export class AssetService {
|
|||||||
|
|
||||||
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
|
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
|
||||||
|
|
||||||
await this.videoConversionQueue.add(JobName.MP4_CONVERSION, { asset: livePhotoAssetEntity });
|
await this.videoConversionQueue.add(JobName.VIDEO_CONVERSION, { asset: livePhotoAssetEntity });
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetEntity = await this.createUserAsset(
|
const assetEntity = await this.createUserAsset(
|
||||||
@ -456,7 +456,7 @@ export class AssetService {
|
|||||||
|
|
||||||
await fs.access(videoPath, constants.R_OK | constants.W_OK);
|
await fs.access(videoPath, constants.R_OK | constants.W_OK);
|
||||||
|
|
||||||
if (query.isWeb && !assetUtils.isWebPlayable(asset.mimeType)) {
|
if (asset.encodedVideoPath) {
|
||||||
videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
|
videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
|
||||||
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
|
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,8 @@ import {
|
|||||||
IMetadataExtractionJob,
|
IMetadataExtractionJob,
|
||||||
IThumbnailGenerationJob,
|
IThumbnailGenerationJob,
|
||||||
IVideoTranscodeJob,
|
IVideoTranscodeJob,
|
||||||
QueueName,
|
|
||||||
JobName,
|
JobName,
|
||||||
|
QueueName,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
@ -53,7 +53,7 @@ export class JobService {
|
|||||||
case JobId.METADATA_EXTRACTION:
|
case JobId.METADATA_EXTRACTION:
|
||||||
return this.runMetadataExtractionJob();
|
return this.runMetadataExtractionJob();
|
||||||
case JobId.VIDEO_CONVERSION:
|
case JobId.VIDEO_CONVERSION:
|
||||||
return 0;
|
return this.runVideoConversionJob();
|
||||||
case JobId.MACHINE_LEARNING:
|
case JobId.MACHINE_LEARNING:
|
||||||
return this.runMachineLearningPipeline();
|
return this.runMachineLearningPipeline();
|
||||||
case JobId.STORAGE_TEMPLATE_MIGRATION:
|
case JobId.STORAGE_TEMPLATE_MIGRATION:
|
||||||
@ -79,7 +79,6 @@ export class JobService {
|
|||||||
response.videoConversionQueueCount = videoConversionJobCount;
|
response.videoConversionQueueCount = videoConversionJobCount;
|
||||||
response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting);
|
response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting);
|
||||||
response.machineLearningQueueCount = machineLearningJobCount;
|
response.machineLearningQueueCount = machineLearningJobCount;
|
||||||
|
|
||||||
response.isStorageMigrationActive = Boolean(storageMigrationJobCount.active);
|
response.isStorageMigrationActive = Boolean(storageMigrationJobCount.active);
|
||||||
response.storageMigrationQueueCount = storageMigrationJobCount;
|
response.storageMigrationQueueCount = storageMigrationJobCount;
|
||||||
|
|
||||||
@ -188,6 +187,22 @@ export class JobService {
|
|||||||
return assetWithNoSmartInfo.length;
|
return assetWithNoSmartInfo.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async runVideoConversionJob(): Promise<number> {
|
||||||
|
const jobCount = await this.videoConversionQueue.getJobCounts();
|
||||||
|
|
||||||
|
if (jobCount.waiting > 0) {
|
||||||
|
throw new BadRequestException('Video conversion job is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetsWithNoConvertedVideo = await this._assetRepository.getAssetWithNoEncodedVideo();
|
||||||
|
|
||||||
|
for (const asset of assetsWithNoConvertedVideo) {
|
||||||
|
await this.videoConversionQueue.add(JobName.VIDEO_CONVERSION, { asset });
|
||||||
|
}
|
||||||
|
|
||||||
|
return assetsWithNoConvertedVideo.length;
|
||||||
|
}
|
||||||
|
|
||||||
async runStorageMigration() {
|
async runStorageMigration() {
|
||||||
const jobCount = await this.configQueue.getJobCounts();
|
const jobCount = await this.configQueue.getJobCounts();
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ export class ScheduleTasksService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
await this.videoConversionQueue.add(JobName.MP4_CONVERSION, { asset });
|
await this.videoConversionQueue.add(JobName.VIDEO_CONVERSION, { asset });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ export class AssetUploadedProcessor {
|
|||||||
|
|
||||||
// Video Conversion
|
// Video Conversion
|
||||||
if (asset.type == AssetType.VIDEO) {
|
if (asset.type == AssetType.VIDEO) {
|
||||||
await this.videoConversionQueue.add(JobName.MP4_CONVERSION, { asset });
|
await this.videoConversionQueue.add(JobName.VIDEO_CONVERSION, { asset });
|
||||||
await this.metadataExtractionQueue.add(JobName.EXTRACT_VIDEO_METADATA, { asset, fileName });
|
await this.metadataExtractionQueue.add(JobName.EXTRACT_VIDEO_METADATA, { asset, fileName });
|
||||||
} else {
|
} else {
|
||||||
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||||
import { AssetEntity } from '@app/infra';
|
import { AssetEntity } from '@app/infra';
|
||||||
import { QueueName, JobName } from '@app/domain';
|
import { IVideoConversionProcessor, JobName, QueueName, SystemConfigService } from '@app/domain';
|
||||||
import { IMp4ConversionProcessor } from '@app/domain';
|
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Job } from 'bull';
|
import { Job } from 'bull';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { SystemConfigService } from '@app/domain';
|
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
@Processor(QueueName.VIDEO_CONVERSION)
|
@Processor(QueueName.VIDEO_CONVERSION)
|
||||||
@ -19,11 +17,10 @@ export class VideoTranscodeProcessor {
|
|||||||
private systemConfigService: SystemConfigService,
|
private systemConfigService: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Process({ name: JobName.MP4_CONVERSION, concurrency: 2 })
|
@Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 })
|
||||||
async mp4Conversion(job: Job<IMp4ConversionProcessor>) {
|
async videoConversion(job: Job<IVideoConversionProcessor>) {
|
||||||
const { asset } = job.data;
|
const { asset } = job.data;
|
||||||
|
|
||||||
if (asset.mimeType != 'video/mp4') {
|
|
||||||
const basePath = APP_UPLOAD_LOCATION;
|
const basePath = APP_UPLOAD_LOCATION;
|
||||||
const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`;
|
const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`;
|
||||||
|
|
||||||
@ -31,13 +28,50 @@ export class VideoTranscodeProcessor {
|
|||||||
mkdirSync(encodedVideoPath, { recursive: true });
|
mkdirSync(encodedVideoPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedEncodedPath = encodedVideoPath + '/' + asset.id + '.mp4';
|
const savedEncodedPath = `${encodedVideoPath}/${asset.id}.mp4`;
|
||||||
|
|
||||||
if (asset.encodedVideoPath == '' || !asset.encodedVideoPath) {
|
if (!asset.encodedVideoPath) {
|
||||||
// Put the processing into its own async function to prevent the job exist right away
|
// Put the processing into its own async function to prevent the job exist right away
|
||||||
await this.runFFMPEGPipeLine(asset, savedEncodedPath);
|
await this.runVideoEncode(asset, savedEncodedPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runFFProbePipeline(asset: AssetEntity): Promise<FfprobeData> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||||
|
if (err || !data) {
|
||||||
|
Logger.error(`Cannot probe video ${err}`, 'mp4Conversion');
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async runVideoEncode(asset: AssetEntity, savedEncodedPath: string): Promise<void> {
|
||||||
|
const config = await this.systemConfigService.getConfig();
|
||||||
|
|
||||||
|
if (config.ffmpeg.transcodeAll) {
|
||||||
|
return this.runFFMPEGPipeLine(asset, savedEncodedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoInfo = await this.runFFProbePipeline(asset);
|
||||||
|
|
||||||
|
const videoStreams = videoInfo.streams.filter((stream) => {
|
||||||
|
return stream.codec_type === 'video';
|
||||||
|
});
|
||||||
|
|
||||||
|
const longestVideoStream = videoStreams.sort((stream1, stream2) => {
|
||||||
|
const stream1Frames = Number.parseInt(stream1.nb_frames ?? '0');
|
||||||
|
const stream2Frames = Number.parseInt(stream2.nb_frames ?? '0');
|
||||||
|
return stream2Frames - stream1Frames;
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
//TODO: If video or audio are already the correct format, don't re-encode, copy the stream
|
||||||
|
if (longestVideoStream.codec_name !== config.ffmpeg.targetVideoCodec) {
|
||||||
|
return this.runFFMPEGPipeLine(asset, savedEncodedPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async runFFMPEGPipeLine(asset: AssetEntity, savedEncodedPath: string): Promise<void> {
|
async runFFMPEGPipeLine(asset: AssetEntity, savedEncodedPath: string): Promise<void> {
|
||||||
|
@ -2899,6 +2899,9 @@
|
|||||||
},
|
},
|
||||||
"targetScaling": {
|
"targetScaling": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"transcodeAll": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -2906,7 +2909,8 @@
|
|||||||
"preset",
|
"preset",
|
||||||
"targetVideoCodec",
|
"targetVideoCodec",
|
||||||
"targetAudioCodec",
|
"targetAudioCodec",
|
||||||
"targetScaling"
|
"targetScaling",
|
||||||
|
"transcodeAll"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"SystemConfigOAuthDto": {
|
"SystemConfigOAuthDto": {
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { AssetEntity } from '@app/infra/db/entities';
|
import { AssetEntity } from '@app/infra/db/entities';
|
||||||
|
|
||||||
export interface IMp4ConversionProcessor {
|
export interface IVideoConversionProcessor {
|
||||||
/**
|
/**
|
||||||
* The Asset entity that was saved in the database
|
* The Asset entity that was saved in the database
|
||||||
*/
|
*/
|
||||||
asset: AssetEntity;
|
asset: AssetEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IVideoTranscodeJob = IMp4ConversionProcessor;
|
export type IVideoTranscodeJob = IVideoConversionProcessor;
|
||||||
|
@ -12,7 +12,7 @@ export enum QueueName {
|
|||||||
|
|
||||||
export enum JobName {
|
export enum JobName {
|
||||||
ASSET_UPLOADED = 'asset-uploaded',
|
ASSET_UPLOADED = 'asset-uploaded',
|
||||||
MP4_CONVERSION = 'mp4-conversion',
|
VIDEO_CONVERSION = 'mp4-conversion',
|
||||||
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
|
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
|
||||||
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
||||||
EXIF_EXTRACTION = 'exif-extraction',
|
EXIF_EXTRACTION = 'exif-extraction',
|
||||||
|
@ -3,7 +3,7 @@ import {
|
|||||||
IDeleteFileOnDiskJob,
|
IDeleteFileOnDiskJob,
|
||||||
IExifExtractionProcessor,
|
IExifExtractionProcessor,
|
||||||
IMachineLearningJob,
|
IMachineLearningJob,
|
||||||
IMp4ConversionProcessor,
|
IVideoConversionProcessor,
|
||||||
IReverseGeocodingProcessor,
|
IReverseGeocodingProcessor,
|
||||||
IUserDeletionJob,
|
IUserDeletionJob,
|
||||||
JpegGeneratorProcessor,
|
JpegGeneratorProcessor,
|
||||||
@ -13,7 +13,7 @@ import { JobName } from './job.constants';
|
|||||||
|
|
||||||
export type JobItem =
|
export type JobItem =
|
||||||
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
|
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
|
||||||
| { name: JobName.MP4_CONVERSION; data: IMp4ConversionProcessor }
|
| { name: JobName.VIDEO_CONVERSION; data: IVideoConversionProcessor }
|
||||||
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: JpegGeneratorProcessor }
|
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: JpegGeneratorProcessor }
|
||||||
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: WebpGeneratorProcessor }
|
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: WebpGeneratorProcessor }
|
||||||
| { name: JobName.EXIF_EXTRACTION; data: IExifExtractionProcessor }
|
| { name: JobName.EXIF_EXTRACTION; data: IExifExtractionProcessor }
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IsString } from 'class-validator';
|
import { IsBoolean, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class SystemConfigFFmpegDto {
|
export class SystemConfigFFmpegDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -15,4 +15,7 @@ export class SystemConfigFFmpegDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
targetScaling!: string;
|
targetScaling!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
transcodeAll!: boolean;
|
||||||
}
|
}
|
||||||
|
@ -11,9 +11,10 @@ const defaults: SystemConfig = Object.freeze({
|
|||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: '23',
|
crf: '23',
|
||||||
preset: 'ultrafast',
|
preset: 'ultrafast',
|
||||||
targetVideoCodec: 'libx264',
|
targetVideoCodec: 'h264',
|
||||||
targetAudioCodec: 'mp3',
|
targetAudioCodec: 'aac',
|
||||||
targetScaling: '1280:-2',
|
targetScaling: '1280:-2',
|
||||||
|
transcodeAll: false,
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
@ -15,9 +15,10 @@ const updatedConfig = Object.freeze({
|
|||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: 'a new value',
|
crf: 'a new value',
|
||||||
preset: 'ultrafast',
|
preset: 'ultrafast',
|
||||||
targetAudioCodec: 'mp3',
|
targetAudioCodec: 'aac',
|
||||||
targetScaling: '1280:-2',
|
targetScaling: '1280:-2',
|
||||||
targetVideoCodec: 'libx264',
|
targetVideoCodec: 'h264',
|
||||||
|
transcodeAll: false,
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
autoLaunch: true,
|
autoLaunch: true,
|
||||||
|
@ -48,9 +48,10 @@ export const systemConfigStub = {
|
|||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: '23',
|
crf: '23',
|
||||||
preset: 'ultrafast',
|
preset: 'ultrafast',
|
||||||
targetAudioCodec: 'mp3',
|
targetAudioCodec: 'aac',
|
||||||
targetScaling: '1280:-2',
|
targetScaling: '1280:-2',
|
||||||
targetVideoCodec: 'libx264',
|
targetVideoCodec: 'h264',
|
||||||
|
transcodeAll: false,
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
autoLaunch: false,
|
autoLaunch: false,
|
||||||
|
@ -18,6 +18,7 @@ export enum SystemConfigKey {
|
|||||||
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
|
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
|
||||||
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
|
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
|
||||||
FFMPEG_TARGET_SCALING = 'ffmpeg.targetScaling',
|
FFMPEG_TARGET_SCALING = 'ffmpeg.targetScaling',
|
||||||
|
FFMPEG_TRANSCODE_ALL = 'ffmpeg.transcodeAll',
|
||||||
OAUTH_ENABLED = 'oauth.enabled',
|
OAUTH_ENABLED = 'oauth.enabled',
|
||||||
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
|
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
|
||||||
OAUTH_CLIENT_ID = 'oauth.clientId',
|
OAUTH_CLIENT_ID = 'oauth.clientId',
|
||||||
@ -39,6 +40,7 @@ export interface SystemConfig {
|
|||||||
targetVideoCodec: string;
|
targetVideoCodec: string;
|
||||||
targetAudioCodec: string;
|
targetAudioCodec: string;
|
||||||
targetScaling: string;
|
targetScaling: string;
|
||||||
|
transcodeAll: boolean;
|
||||||
};
|
};
|
||||||
oauth: {
|
oauth: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
|
export class RemoveVideoCodecConfigOption1674263302006 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'ffmpeg.targetVideoCodec'`);
|
||||||
|
await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'ffmpeg.targetAudioCodec'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
27
web/src/api/open-api/api.ts
generated
27
web/src/api/open-api/api.ts
generated
@ -4,7 +4,7 @@
|
|||||||
* Immich
|
* Immich
|
||||||
* Immich API
|
* Immich API
|
||||||
*
|
*
|
||||||
* The version of the OpenAPI document: 1.41.1
|
* The version of the OpenAPI document: 1.42.0
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
@ -13,13 +13,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
import { Configuration } from './configuration';
|
import {Configuration} from './configuration';
|
||||||
import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
|
import globalAxios, {AxiosInstance, AxiosPromise, AxiosRequestConfig} from 'axios';
|
||||||
// Some imports not used depending on template conditions
|
// Some imports not used depending on template conditions
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from './common';
|
import {
|
||||||
|
assertParamExists,
|
||||||
|
createRequestFunction,
|
||||||
|
DUMMY_BASE_URL,
|
||||||
|
serializeDataIfNeeded,
|
||||||
|
setApiKeyToObject,
|
||||||
|
setBasicAuthToObject,
|
||||||
|
setBearerAuthToObject,
|
||||||
|
setOAuthToObject,
|
||||||
|
setSearchParams,
|
||||||
|
toPathString
|
||||||
|
} from './common';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from './base';
|
import {BASE_PATH, BaseAPI, COLLECTION_FORMATS, RequestArgs, RequiredError} from './base';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -1799,6 +1810,12 @@ export interface SystemConfigFFmpegDto {
|
|||||||
* @memberof SystemConfigFFmpegDto
|
* @memberof SystemConfigFFmpegDto
|
||||||
*/
|
*/
|
||||||
'targetScaling': string;
|
'targetScaling': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigFFmpegDto
|
||||||
|
*/
|
||||||
|
'transcodeAll': boolean;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
2
web/src/api/open-api/base.ts
generated
2
web/src/api/open-api/base.ts
generated
@ -4,7 +4,7 @@
|
|||||||
* Immich
|
* Immich
|
||||||
* Immich API
|
* Immich API
|
||||||
*
|
*
|
||||||
* The version of the OpenAPI document: 1.41.1
|
* The version of the OpenAPI document: 1.42.0
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
2
web/src/api/open-api/common.ts
generated
2
web/src/api/open-api/common.ts
generated
@ -4,7 +4,7 @@
|
|||||||
* Immich
|
* Immich
|
||||||
* Immich API
|
* Immich API
|
||||||
*
|
*
|
||||||
* The version of the OpenAPI document: 1.41.1
|
* The version of the OpenAPI document: 1.42.0
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
2
web/src/api/open-api/configuration.ts
generated
2
web/src/api/open-api/configuration.ts
generated
@ -4,7 +4,7 @@
|
|||||||
* Immich
|
* Immich
|
||||||
* Immich API
|
* Immich API
|
||||||
*
|
*
|
||||||
* The version of the OpenAPI document: 1.41.1
|
* The version of the OpenAPI document: 1.42.0
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
2
web/src/api/open-api/index.ts
generated
2
web/src/api/open-api/index.ts
generated
@ -4,7 +4,7 @@
|
|||||||
* Immich
|
* Immich
|
||||||
* Immich API
|
* Immich API
|
||||||
*
|
*
|
||||||
* The version of the OpenAPI document: 1.41.1
|
* The version of the OpenAPI document: 1.42.0
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: `Thumbnail generation job started for ${data} asset`,
|
message: `Thumbnail generation job started for ${data} assets`,
|
||||||
type: NotificationType.Info
|
type: NotificationType.Info
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: `Extract EXIF job started for ${data} asset`,
|
message: `Extract EXIF job started for ${data} assets`,
|
||||||
type: NotificationType.Info
|
type: NotificationType.Info
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: `Object detection job started for ${data} asset`,
|
message: `Object detection job started for ${data} assets`,
|
||||||
type: NotificationType.Info
|
type: NotificationType.Info
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -101,6 +101,28 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runVideoConversion = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.jobApi.sendJobCommand(JobId.VideoConversion, {
|
||||||
|
command: JobCommand.Start
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
notificationController.show({
|
||||||
|
message: `Video conversion job started for ${data} assets`,
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notificationController.show({
|
||||||
|
message: `No videos without an encoded version found`,
|
||||||
|
type: NotificationType.Info
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, `Error running video conversion job, check console for more detail`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runTemplateMigration = async () => {
|
const runTemplateMigration = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.jobApi.sendJobCommand(JobId.StorageTemplateMigration, {
|
const { data } = await api.jobApi.sendJobCommand(JobId.StorageTemplateMigration, {
|
||||||
@ -159,6 +181,17 @@
|
|||||||
Note that some assets may not have any objects detected, this is normal.
|
Note that some assets may not have any objects detected, this is normal.
|
||||||
</JobTile>
|
</JobTile>
|
||||||
|
|
||||||
|
<JobTile
|
||||||
|
title={'Video transcoding'}
|
||||||
|
subtitle={'Run video transcoding process to transcode videos not in the desired format'}
|
||||||
|
on:click={runVideoConversion}
|
||||||
|
jobStatus={allJobsStatus?.isVideoConversionActive}
|
||||||
|
waitingJobCount={allJobsStatus?.videoConversionQueueCount.waiting}
|
||||||
|
activeJobCount={allJobsStatus?.videoConversionQueueCount.active}
|
||||||
|
>
|
||||||
|
Note that some videos won't require transcoding, this is normal.
|
||||||
|
</JobTile>
|
||||||
|
|
||||||
<JobTile
|
<JobTile
|
||||||
title={'Storage migration'}
|
title={'Storage migration'}
|
||||||
subtitle={''}
|
subtitle={''}
|
||||||
|
@ -6,10 +6,13 @@
|
|||||||
import { api, SystemConfigFFmpegDto } from '@api';
|
import { api, SystemConfigFFmpegDto } from '@api';
|
||||||
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
||||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||||
|
import SettingSelect from '../setting-select.svelte';
|
||||||
|
import SettingSwitch from '../setting-switch.svelte';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
export let ffmpegConfig: SystemConfigFFmpegDto; // this is the config that is being edited
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
export let ffmpegConfig: SystemConfigFFmpegDto; // this is the config that is being edited
|
||||||
|
|
||||||
let savedConfig: SystemConfigFFmpegDto;
|
let savedConfig: SystemConfigFFmpegDto;
|
||||||
let defaultConfig: SystemConfigFFmpegDto;
|
let defaultConfig: SystemConfigFFmpegDto;
|
||||||
|
|
||||||
@ -99,11 +102,10 @@
|
|||||||
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
|
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingInputField
|
<SettingSelect
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label="VIDEO CODEC (-vcodec)"
|
label="VIDEO CODEC (-vcodec)"
|
||||||
bind:value={ffmpegConfig.targetVideoCodec}
|
bind:value={ffmpegConfig.targetVideoCodec}
|
||||||
required={true}
|
options={['h264', 'hevc', 'vp9']}
|
||||||
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
|
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -114,6 +116,13 @@
|
|||||||
required={true}
|
required={true}
|
||||||
isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)}
|
isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingSwitch
|
||||||
|
title="TRANSCODE ALL"
|
||||||
|
subtitle="Transcode all files, even if they already match the specified format?"
|
||||||
|
bind:checked={ffmpegConfig.transcodeAll}
|
||||||
|
isEdited={!(ffmpegConfig.transcodeAll == savedConfig.transcodeAll)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
export let value: string;
|
||||||
|
export let options: string[];
|
||||||
|
export let label = '';
|
||||||
|
export let isEdited = false;
|
||||||
|
|
||||||
|
const handleChange = (e: Event) => {
|
||||||
|
value = (e.target as HTMLInputElement).value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<div class={`flex place-items-center gap-1 h-[26px]`}>
|
||||||
|
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
|
||||||
|
|
||||||
|
{#if isEdited}
|
||||||
|
<div
|
||||||
|
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||||
|
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
|
||||||
|
>
|
||||||
|
Unsaved change
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="immich-form-input w-full"
|
||||||
|
name="presets"
|
||||||
|
id="preset-select"
|
||||||
|
bind:value
|
||||||
|
on:change={handleChange}
|
||||||
|
>
|
||||||
|
{#each options as option}
|
||||||
|
<option value={option}>{option}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
@ -1,15 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let subtitle = '';
|
export let subtitle = '';
|
||||||
export let checked = false;
|
export let checked = false;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
|
export let isEdited = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex justify-between place-items-center">
|
<div class="flex justify-between place-items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="immich-form-label text-sm">
|
<div class="flex place-items-center gap-1 h-[26px]">
|
||||||
|
<label class="immich-form-label text-sm" for={title}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</label>
|
||||||
|
{#if isEdited}
|
||||||
|
<div
|
||||||
|
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||||
|
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
|
||||||
|
>
|
||||||
|
Unsaved change
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
|
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user