From 4e0fe27de3677635c4374fff8f8fd462ff793fdc Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sun, 22 Jan 2023 02:09:02 +0000 Subject: [PATCH] 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 --- .gitignore | 3 +- mobile/openapi/README.md | 2 +- mobile/openapi/doc/SystemConfigFFmpegDto.md | 1 + .../lib/model/system_config_f_fmpeg_dto.dart | 14 +++- .../test/system_config_f_fmpeg_dto_test.dart | 5 ++ .../src/api-v1/asset/asset-repository.ts | 10 +++ .../src/api-v1/asset/asset.service.spec.ts | 1 + .../immich/src/api-v1/asset/asset.service.ts | 8 +-- .../apps/immich/src/api-v1/job/job.service.ts | 21 +++++- .../schedule-tasks/schedule-tasks.service.ts | 2 +- .../processors/asset-uploaded.processor.ts | 2 +- .../processors/video-transcode.processor.ts | 68 ++++++++++++++----- server/immich-openapi-specs.json | 6 +- .../interfaces/video-transcode.interface.ts | 4 +- server/libs/domain/src/job/job.constants.ts | 2 +- server/libs/domain/src/job/job.repository.ts | 4 +- .../dto/system-config-ffmpeg.dto.ts | 5 +- .../src/system-config/system-config.core.ts | 5 +- .../system-config.service.spec.ts | 5 +- server/libs/domain/test/fixtures.ts | 5 +- .../src/db/entities/system-config.entity.ts | 2 + ...4263302005-RemoveVideoCodecConfigOption.ts | 12 ++++ web/src/api/open-api/api.ts | 27 ++++++-- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- .../admin-page/jobs/jobs-panel.svelte | 39 ++++++++++- .../settings/ffmpeg/ffmpeg-settings.svelte | 17 +++-- .../admin-page/settings/setting-select.svelte | 39 +++++++++++ .../admin-page/settings/setting-switch.svelte | 20 +++++- 31 files changed, 274 insertions(+), 63 deletions(-) create mode 100644 server/libs/infra/src/db/migrations/1674263302005-RemoveVideoCodecConfigOption.ts create mode 100644 web/src/lib/components/admin-page/settings/setting-select.svelte diff --git a/.gitignore b/.gitignore index 58561174..984d98d4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ .idea docker/upload -coverage +uploads +coverage \ No newline at end of file diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 46a36d83..d2a1a0c8 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.41.1 +- API version: 1.42.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md index b208d7b9..cfe86a5a 100644 --- a/mobile/openapi/doc/SystemConfigFFmpegDto.md +++ b/mobile/openapi/doc/SystemConfigFFmpegDto.md @@ -13,6 +13,7 @@ Name | Type | Description | Notes **targetVideoCodec** | **String** | | **targetAudioCodec** | **String** | | **targetScaling** | **String** | | +**transcodeAll** | **bool** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index 064bdd36..9c929b65 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -18,6 +18,7 @@ class SystemConfigFFmpegDto { required this.targetVideoCodec, required this.targetAudioCodec, required this.targetScaling, + required this.transcodeAll, }); String crf; @@ -30,13 +31,16 @@ class SystemConfigFFmpegDto { String targetScaling; + bool transcodeAll; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegDto && other.crf == crf && other.preset == preset && other.targetVideoCodec == targetVideoCodec && other.targetAudioCodec == targetAudioCodec && - other.targetScaling == targetScaling; + other.targetScaling == targetScaling && + other.transcodeAll == transcodeAll; @override int get hashCode => @@ -45,10 +49,11 @@ class SystemConfigFFmpegDto { (preset.hashCode) + (targetVideoCodec.hashCode) + (targetAudioCodec.hashCode) + - (targetScaling.hashCode); + (targetScaling.hashCode) + + (transcodeAll.hashCode); @override - String toString() => 'SystemConfigFFmpegDto[crf=$crf, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetScaling=$targetScaling]'; + String toString() => 'SystemConfigFFmpegDto[crf=$crf, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetScaling=$targetScaling, transcodeAll=$transcodeAll]'; Map toJson() { final json = {}; @@ -57,6 +62,7 @@ class SystemConfigFFmpegDto { json[r'targetVideoCodec'] = this.targetVideoCodec; json[r'targetAudioCodec'] = this.targetAudioCodec; json[r'targetScaling'] = this.targetScaling; + json[r'transcodeAll'] = this.transcodeAll; return json; } @@ -84,6 +90,7 @@ class SystemConfigFFmpegDto { targetVideoCodec: mapValueOfType(json, r'targetVideoCodec')!, targetAudioCodec: mapValueOfType(json, r'targetAudioCodec')!, targetScaling: mapValueOfType(json, r'targetScaling')!, + transcodeAll: mapValueOfType(json, r'transcodeAll')!, ); } return null; @@ -138,6 +145,7 @@ class SystemConfigFFmpegDto { 'targetVideoCodec', 'targetAudioCodec', 'targetScaling', + 'transcodeAll', }; } diff --git a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart index a088dc61..e0b862f1 100644 --- a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart +++ b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart @@ -41,6 +41,11 @@ void main() { // TODO }); + // bool transcodeAll + test('to test the property `transcodeAll`', () async { + // TODO + }); + }); diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 37893b4a..741cd385 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -39,6 +39,7 @@ export interface IAssetRepository { getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise; getAssetByChecksum(userId: string, checksum: Buffer): Promise; getAssetWithNoThumbnail(): Promise; + getAssetWithNoEncodedVideo(): Promise; getAssetWithNoEXIF(): Promise; getAssetWithNoSmartInfo(): Promise; getExistingAssets( @@ -80,6 +81,15 @@ export class AssetRepository implements IAssetRepository { }); } + async getAssetWithNoEncodedVideo(): Promise { + return await this.assetRepository.find({ + where: [ + { type: AssetType.VIDEO, encodedVideoPath: IsNull() }, + { type: AssetType.VIDEO, encodedVideoPath: '' }, + ], + }); + } + async getAssetWithNoEXIF(): Promise { return await this.assetRepository .createQueryBuilder('asset') diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 02b4511c..a07873d8 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -128,6 +128,7 @@ describe('AssetService', () => { getAssetWithNoEXIF: jest.fn(), getAssetWithNoThumbnail: jest.fn(), getAssetWithNoSmartInfo: jest.fn(), + getAssetWithNoEncodedVideo: jest.fn(), getExistingAssets: jest.fn(), countByIdAndUser: jest.fn(), }; diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index ad582a5d..c4977cd5 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -37,13 +37,13 @@ import { import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-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 { assetUtils, timeUtils } from '@app/common/utils'; +import { timeUtils } from '@app/common/utils'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; 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 { Queue } from 'bull'; import { DownloadService } from '../../modules/download/download.service'; @@ -122,7 +122,7 @@ export class AssetService { 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( @@ -456,7 +456,7 @@ export class AssetService { 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); mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4'; } diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts index 519c527f..4bba764b 100644 --- a/server/apps/immich/src/api-v1/job/job.service.ts +++ b/server/apps/immich/src/api-v1/job/job.service.ts @@ -3,8 +3,8 @@ import { IMetadataExtractionJob, IThumbnailGenerationJob, IVideoTranscodeJob, - QueueName, JobName, + QueueName, } from '@app/domain'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; @@ -53,7 +53,7 @@ export class JobService { case JobId.METADATA_EXTRACTION: return this.runMetadataExtractionJob(); case JobId.VIDEO_CONVERSION: - return 0; + return this.runVideoConversionJob(); case JobId.MACHINE_LEARNING: return this.runMachineLearningPipeline(); case JobId.STORAGE_TEMPLATE_MIGRATION: @@ -79,7 +79,6 @@ export class JobService { response.videoConversionQueueCount = videoConversionJobCount; response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting); response.machineLearningQueueCount = machineLearningJobCount; - response.isStorageMigrationActive = Boolean(storageMigrationJobCount.active); response.storageMigrationQueueCount = storageMigrationJobCount; @@ -188,6 +187,22 @@ export class JobService { return assetWithNoSmartInfo.length; } + private async runVideoConversionJob(): Promise { + 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() { const jobCount = await this.configQueue.getJobCounts(); diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts index 3cd565cc..e1e92dcd 100644 --- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts +++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts @@ -69,7 +69,7 @@ export class ScheduleTasksService { }); for (const asset of assets) { - await this.videoConversionQueue.add(JobName.MP4_CONVERSION, { asset }); + await this.videoConversionQueue.add(JobName.VIDEO_CONVERSION, { asset }); } } diff --git a/server/apps/microservices/src/processors/asset-uploaded.processor.ts b/server/apps/microservices/src/processors/asset-uploaded.processor.ts index c7602f46..ea0005ee 100644 --- a/server/apps/microservices/src/processors/asset-uploaded.processor.ts +++ b/server/apps/microservices/src/processors/asset-uploaded.processor.ts @@ -40,7 +40,7 @@ export class AssetUploadedProcessor { // Video Conversion 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 }); } else { // Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 170cb70d..52557e62 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -1,14 +1,12 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants'; import { AssetEntity } from '@app/infra'; -import { QueueName, JobName } from '@app/domain'; -import { IMp4ConversionProcessor } from '@app/domain'; +import { IVideoConversionProcessor, JobName, QueueName, SystemConfigService } from '@app/domain'; import { Process, Processor } from '@nestjs/bull'; import { Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Job } from 'bull'; -import ffmpeg from 'fluent-ffmpeg'; +import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import { existsSync, mkdirSync } from 'fs'; -import { SystemConfigService } from '@app/domain'; import { Repository } from 'typeorm'; @Processor(QueueName.VIDEO_CONVERSION) @@ -19,24 +17,60 @@ export class VideoTranscodeProcessor { private systemConfigService: SystemConfigService, ) {} - @Process({ name: JobName.MP4_CONVERSION, concurrency: 2 }) - async mp4Conversion(job: Job) { + @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 }) + async videoConversion(job: Job) { const { asset } = job.data; - if (asset.mimeType != 'video/mp4') { - const basePath = APP_UPLOAD_LOCATION; - const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`; + const basePath = APP_UPLOAD_LOCATION; + const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`; - if (!existsSync(encodedVideoPath)) { - mkdirSync(encodedVideoPath, { recursive: true }); - } + if (!existsSync(encodedVideoPath)) { + mkdirSync(encodedVideoPath, { recursive: true }); + } - const savedEncodedPath = encodedVideoPath + '/' + asset.id + '.mp4'; + const savedEncodedPath = `${encodedVideoPath}/${asset.id}.mp4`; - if (asset.encodedVideoPath == '' || !asset.encodedVideoPath) { - // Put the processing into its own async function to prevent the job exist right away - await this.runFFMPEGPipeLine(asset, savedEncodedPath); - } + if (!asset.encodedVideoPath) { + // Put the processing into its own async function to prevent the job exist right away + await this.runVideoEncode(asset, savedEncodedPath); + } + } + + async runFFProbePipeline(asset: AssetEntity): Promise { + 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 { + 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); } } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index c37159c9..d9f5f23e 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2899,6 +2899,9 @@ }, "targetScaling": { "type": "string" + }, + "transcodeAll": { + "type": "boolean" } }, "required": [ @@ -2906,7 +2909,8 @@ "preset", "targetVideoCodec", "targetAudioCodec", - "targetScaling" + "targetScaling", + "transcodeAll" ] }, "SystemConfigOAuthDto": { diff --git a/server/libs/domain/src/job/interfaces/video-transcode.interface.ts b/server/libs/domain/src/job/interfaces/video-transcode.interface.ts index 6cc8870c..325a491e 100644 --- a/server/libs/domain/src/job/interfaces/video-transcode.interface.ts +++ b/server/libs/domain/src/job/interfaces/video-transcode.interface.ts @@ -1,10 +1,10 @@ import { AssetEntity } from '@app/infra/db/entities'; -export interface IMp4ConversionProcessor { +export interface IVideoConversionProcessor { /** * The Asset entity that was saved in the database */ asset: AssetEntity; } -export type IVideoTranscodeJob = IMp4ConversionProcessor; +export type IVideoTranscodeJob = IVideoConversionProcessor; diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index e66c81e3..dd5d060c 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -12,7 +12,7 @@ export enum QueueName { export enum JobName { ASSET_UPLOADED = 'asset-uploaded', - MP4_CONVERSION = 'mp4-conversion', + VIDEO_CONVERSION = 'mp4-conversion', GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail', GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail', EXIF_EXTRACTION = 'exif-extraction', diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index 66177a49..fe129b77 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -3,7 +3,7 @@ import { IDeleteFileOnDiskJob, IExifExtractionProcessor, IMachineLearningJob, - IMp4ConversionProcessor, + IVideoConversionProcessor, IReverseGeocodingProcessor, IUserDeletionJob, JpegGeneratorProcessor, @@ -13,7 +13,7 @@ import { JobName } from './job.constants'; export type JobItem = | { 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_WEBP_THUMBNAIL; data: WebpGeneratorProcessor } | { name: JobName.EXIF_EXTRACTION; data: IExifExtractionProcessor } diff --git a/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts b/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts index 2b96addb..5dc75c62 100644 --- a/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts +++ b/server/libs/domain/src/system-config/dto/system-config-ffmpeg.dto.ts @@ -1,4 +1,4 @@ -import { IsString } from 'class-validator'; +import { IsBoolean, IsString } from 'class-validator'; export class SystemConfigFFmpegDto { @IsString() @@ -15,4 +15,7 @@ export class SystemConfigFFmpegDto { @IsString() targetScaling!: string; + + @IsBoolean() + transcodeAll!: boolean; } diff --git a/server/libs/domain/src/system-config/system-config.core.ts b/server/libs/domain/src/system-config/system-config.core.ts index 6d1dd53b..36e1b548 100644 --- a/server/libs/domain/src/system-config/system-config.core.ts +++ b/server/libs/domain/src/system-config/system-config.core.ts @@ -11,9 +11,10 @@ const defaults: SystemConfig = Object.freeze({ ffmpeg: { crf: '23', preset: 'ultrafast', - targetVideoCodec: 'libx264', - targetAudioCodec: 'mp3', + targetVideoCodec: 'h264', + targetAudioCodec: 'aac', targetScaling: '1280:-2', + transcodeAll: false, }, oauth: { enabled: false, diff --git a/server/libs/domain/src/system-config/system-config.service.spec.ts b/server/libs/domain/src/system-config/system-config.service.spec.ts index 715ea183..6a2b5ce9 100644 --- a/server/libs/domain/src/system-config/system-config.service.spec.ts +++ b/server/libs/domain/src/system-config/system-config.service.spec.ts @@ -15,9 +15,10 @@ const updatedConfig = Object.freeze({ ffmpeg: { crf: 'a new value', preset: 'ultrafast', - targetAudioCodec: 'mp3', + targetAudioCodec: 'aac', targetScaling: '1280:-2', - targetVideoCodec: 'libx264', + targetVideoCodec: 'h264', + transcodeAll: false, }, oauth: { autoLaunch: true, diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index f7d9222a..a94b24a2 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -48,9 +48,10 @@ export const systemConfigStub = { ffmpeg: { crf: '23', preset: 'ultrafast', - targetAudioCodec: 'mp3', + targetAudioCodec: 'aac', targetScaling: '1280:-2', - targetVideoCodec: 'libx264', + targetVideoCodec: 'h264', + transcodeAll: false, }, oauth: { autoLaunch: false, diff --git a/server/libs/infra/src/db/entities/system-config.entity.ts b/server/libs/infra/src/db/entities/system-config.entity.ts index de9280e4..0c47534c 100644 --- a/server/libs/infra/src/db/entities/system-config.entity.ts +++ b/server/libs/infra/src/db/entities/system-config.entity.ts @@ -18,6 +18,7 @@ export enum SystemConfigKey { FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec', FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec', FFMPEG_TARGET_SCALING = 'ffmpeg.targetScaling', + FFMPEG_TRANSCODE_ALL = 'ffmpeg.transcodeAll', OAUTH_ENABLED = 'oauth.enabled', OAUTH_ISSUER_URL = 'oauth.issuerUrl', OAUTH_CLIENT_ID = 'oauth.clientId', @@ -39,6 +40,7 @@ export interface SystemConfig { targetVideoCodec: string; targetAudioCodec: string; targetScaling: string; + transcodeAll: boolean; }; oauth: { enabled: boolean; diff --git a/server/libs/infra/src/db/migrations/1674263302005-RemoveVideoCodecConfigOption.ts b/server/libs/infra/src/db/migrations/1674263302005-RemoveVideoCodecConfigOption.ts new file mode 100644 index 00000000..5f64b115 --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674263302005-RemoveVideoCodecConfigOption.ts @@ -0,0 +1,12 @@ +import {MigrationInterface, QueryRunner} from 'typeorm'; + +export class RemoveVideoCodecConfigOption1674263302006 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + // noop + } +} diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 5af6cb39..ba8b0e9f 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * 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). @@ -13,13 +13,24 @@ */ -import { Configuration } from './configuration'; -import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; +import {Configuration} from './configuration'; +import globalAxios, {AxiosInstance, AxiosPromise, AxiosRequestConfig} from 'axios'; // Some imports not used depending on template conditions // @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 -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 */ 'targetScaling': string; + /** + * + * @type {boolean} + * @memberof SystemConfigFFmpegDto + */ + 'transcodeAll': boolean; } /** * diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 2d8f7a0a..3902b9e4 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * 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). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index acd18acb..a2e7728e 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * 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). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index 65556da1..ccb51fb3 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * 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). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index ea27b5ef..bcbb0bd7 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * 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). diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index f374340d..5c3dcdc9 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -33,7 +33,7 @@ if (data) { notificationController.show({ - message: `Thumbnail generation job started for ${data} asset`, + message: `Thumbnail generation job started for ${data} assets`, type: NotificationType.Info }); } else { @@ -60,7 +60,7 @@ if (data) { notificationController.show({ - message: `Extract EXIF job started for ${data} asset`, + message: `Extract EXIF job started for ${data} assets`, type: NotificationType.Info }); } else { @@ -87,7 +87,7 @@ if (data) { notificationController.show({ - message: `Object detection job started for ${data} asset`, + message: `Object detection job started for ${data} assets`, type: NotificationType.Info }); } 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 () => { try { 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 videos won't require transcoding, this is normal. + + - @@ -114,6 +116,13 @@ required={true} isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)} /> + +
diff --git a/web/src/lib/components/admin-page/settings/setting-select.svelte b/web/src/lib/components/admin-page/settings/setting-select.svelte new file mode 100644 index 00000000..9f6ff763 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/setting-select.svelte @@ -0,0 +1,39 @@ + + +
+
+ + + {#if isEdited} +
+ Unsaved change +
+ {/if} +
+ +
diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte index e7c591b9..c5816f4e 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -1,15 +1,29 @@
-

- {title} -

+
+ + {#if isEdited} +
+ Unsaved change +
+ {/if} +

{subtitle}