Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/administration/system-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ The default value is `ultrafast`.

### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}

Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.

The default value is `aac`.

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/install/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The default configuration looks like this:
"ffmpeg": {
"accel": "disabled",
"accelDecode": false,
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
"acceptedContainers": ["mov", "ogg", "webm"],
"acceptedVideoCodecs": ["h264"],
"bframes": -1,
Expand Down
3 changes: 3 additions & 0 deletions mobile/openapi/lib/model/audio_codec.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"openapi": "3.0.0",
"paths": {
Expand Down Expand Up @@ -17260,6 +17260,7 @@
"mp3",
"aac",
"libopus",
"opus",
"pcm_s16le"
],
"type": "string"
Expand Down
1 change: 1 addition & 0 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7324,6 +7324,7 @@ export enum AudioCodec {
Mp3 = "mp3",
Aac = "aac",
Libopus = "libopus",
Opus = "opus",
PcmS16Le = "pcm_s16le"
}
export enum VideoContainer {
Expand Down
2 changes: 1 addition & 1 deletion server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export const defaults = Object.freeze<SystemConfig>({
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
targetAudioCodec: AudioCodec.Aac,
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
targetResolution: '720',
maxBitrate: '0',
Expand Down
10 changes: 9 additions & 1 deletion server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';

export const ErrorMessages = {
InconsistentMediaLocation:
Expand Down Expand Up @@ -201,3 +201,11 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Workflows]:
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
};

export const AUDIO_ENCODER: Record<AudioCodec, string> = {
[AudioCodec.Aac]: 'aac',
[AudioCodec.Mp3]: 'mp3',
[AudioCodec.Libopus]: 'libopus',
[AudioCodec.Opus]: 'libopus',
[AudioCodec.PcmS16le]: 'pcm_s16le',
};
12 changes: 11 additions & 1 deletion server/src/dtos/system-config.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsInt,
Expand Down Expand Up @@ -92,6 +92,16 @@ export class SystemConfigFFmpegDto {
targetAudioCodec!: AudioCodec;

@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
@Transform(({ value }) => {
if (Array.isArray(value)) {
const libopusIndex = value.indexOf('libopus');
if (libopusIndex !== -1) {
value[libopusIndex] = 'opus';
}
}

return value;
})
acceptedAudioCodecs!: AudioCodec[];

@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
Expand Down
4 changes: 3 additions & 1 deletion server/src/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,9 @@ export enum VideoCodec {
export enum AudioCodec {
Mp3 = 'mp3',
Aac = 'aac',
LibOpus = 'libopus',
/** @deprecated Use `Opus` instead */
Libopus = 'libopus',
Opus = 'opus',
PcmS16le = 'pcm_s16le',
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<any>): Promise<void> {
await sql`
UPDATE system_metadata
SET value = jsonb_set(
value,
'{ffmpeg,acceptedAudioCodecs}',
(
SELECT jsonb_agg(
CASE
WHEN elem = 'libopus' THEN 'opus'
ELSE elem
END
)
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
)
)
WHERE key = 'system-config'
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'libopus';
`.execute(db);

await sql`
UPDATE system_metadata
SET value = jsonb_set(
value,
'{ffmpeg,targetAudioCodec}',
'"opus"'::jsonb
)
WHERE key = 'system-config'
AND value->'ffmpeg'->>'targetAudioCodec' = 'libopus';
`.execute(db);
}

export async function down(db: Kysely<any>): Promise<void> {
await sql`
UPDATE system_metadata
SET value = jsonb_set(
value,
'{ffmpeg,acceptedAudioCodecs}',
(
SELECT jsonb_agg(
CASE
WHEN elem = 'opus' THEN 'libopus'
ELSE elem
END
)
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
)
)
WHERE key = 'system-config'
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'opus';
`.execute(db);

await sql`
UPDATE system_metadata
SET value = jsonb_set(
value,
'{ffmpeg,targetAudioCodec}',
'"libopus"'::jsonb
)
WHERE key = 'system-config'
AND value->'ffmpeg'->>'targetAudioCodec' = 'opus';
`.execute(db);
}
44 changes: 44 additions & 0 deletions server/src/services/media.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2571,6 +2571,50 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).not.toHaveBeenCalled();
});

describe('should skip transcoding for accepted audio codecs with optimal policy if video is fine', () => {
const acceptedCodecs = [
{ codec: 'aac', probeStub: probeStub.audioStreamAac },
{ codec: 'mp3', probeStub: probeStub.audioStreamMp3 },
{ codec: 'opus', probeStub: probeStub.audioStreamOpus },
];

beforeEach(() => {
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: {
targetVideoCodec: VideoCodec.Hevc,
transcode: TranscodePolicy.Optimal,
targetResolution: '1080p',
},
});
});

it.each(acceptedCodecs)('should skip $codec', async ({ probeStub }) => {
mocks.media.probe.mockResolvedValue(probeStub);
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).not.toHaveBeenCalled();
});
});

it('should use libopus audio encoder when target audio is opus', async () => {
mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac);
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: {
targetAudioCodec: AudioCodec.Opus,
transcode: TranscodePolicy.All,
},
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:a libopus']),
twoPass: false,
}),
);
});

it('should fail if hwaccel is enabled for an unsupported codec', async () => {
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
mocks.systemMetadata.get.mockResolvedValue({
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/system-config.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
threads: 0,
preset: 'ultrafast',
targetAudioCodec: AudioCodec.Aac,
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
targetResolution: '720',
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
Expand Down
7 changes: 4 additions & 3 deletions server/src/utils/media.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AUDIO_ENCODER } from 'src/constants';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
import {
Expand Down Expand Up @@ -117,7 +118,7 @@ export class BaseConfig implements VideoCodecSWConfig {

getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy';
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioCodec() : 'copy';
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';

const options = [
`-c:v ${videoCodec}`,
Expand Down Expand Up @@ -305,8 +306,8 @@ export class BaseConfig implements VideoCodecSWConfig {
return [options];
}

getAudioCodec(): string {
return this.config.targetAudioCodec;
getAudioEncoder(): string {
return AUDIO_ENCODER[this.config.targetAudioCodec];
}

getVideoCodec(): string {
Expand Down
8 changes: 8 additions & 0 deletions server/test/fixtures/media.stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ export const probeStub = {
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
}),
audioStreamMp3: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
}),
audioStreamOpus: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
}),
audioStreamUnknown: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [
Expand Down
4 changes: 2 additions & 2 deletions web/src/lib/components/admin-settings/FFmpegSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
options={[
{ value: AudioCodec.Aac, text: 'AAC' },
{ value: AudioCodec.Mp3, text: 'MP3' },
{ value: AudioCodec.Libopus, text: 'Opus' },
{ value: AudioCodec.Opus, text: 'Opus' },
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
]}
isEdited={!isEqual(
Expand Down Expand Up @@ -174,7 +174,7 @@
options={[
{ value: AudioCodec.Aac, text: 'aac' },
{ value: AudioCodec.Mp3, text: 'mp3' },
{ value: AudioCodec.Libopus, text: 'opus' },
{ value: AudioCodec.Opus, text: 'opus' },
]}
name="acodec"
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
Expand Down
Loading