mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: add support for loudnorm audio filter
Allows for better loudness normalization vs straight volume adjustment using the ffmpeg loudnorm filter. The i, lra, and tp values are all configurable in the advanced transcode settings.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -27,8 +27,8 @@
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
"@typescript-eslint/parser": "^8.21.0",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"esbuild": "^0.21.5",
|
||||
"eslint": "catalog:",
|
||||
@@ -62,9 +62,8 @@
|
||||
"kysely": "patches/kysely.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint": "9.39.2",
|
||||
"@types/node": "22.10.7",
|
||||
"typescript": "5.9.3"
|
||||
"eslint": "catalog:",
|
||||
"@types/node": "22.10.7"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@swc/core",
|
||||
|
||||
1183
pnpm-lock.yaml
generated
1183
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,13 @@ packages:
|
||||
- shared
|
||||
|
||||
catalog:
|
||||
'@typescript-eslint/eslint-plugin': ^8.55.0
|
||||
'@typescript-eslint/parser': ^8.55.0
|
||||
dayjs: ^1.11.14
|
||||
eslint: 9.17.0
|
||||
eslint: 9.39.2
|
||||
lodash-es: ^4.17.21
|
||||
random-js: 2.1.0
|
||||
typescript: 5.7.3
|
||||
zod: ^4.1.5
|
||||
typescript: 5.9.3
|
||||
zod: ^4.3.6
|
||||
|
||||
enablePrePostScripts: true
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"test:watch": "vitest --typecheck.tsconfig tsconfig.test.json --watch",
|
||||
"test": "vitest --typecheck.tsconfig tsconfig.test.json --run",
|
||||
"tunarr": "dotenv -e .env.development -- tsx src/index.ts",
|
||||
"typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json --noEmit"
|
||||
"typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json --noEmit --diagnostics"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cospired/i18n-iso-languages": "^4.2.0",
|
||||
@@ -79,7 +79,7 @@
|
||||
"pino": "^9.9.1",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"pino-roll": "^1.3.0",
|
||||
"random-js": "2.1.0",
|
||||
"random-js": "catalog:",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"retry": "^0.13.1",
|
||||
"sonic-boom": "4.2.0",
|
||||
@@ -88,7 +88,7 @@
|
||||
"tslib": "^2.8.1",
|
||||
"uuid": "^9.0.1",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^4.1.5"
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
@@ -131,7 +131,7 @@
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"typescript": "5.7.3",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "^8.41.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
@@ -168,7 +168,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
||||
'/transcode_configs',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Settings'],
|
||||
tags: ['Settings', 'Transcode Configs'],
|
||||
response: {
|
||||
200: z.array(TranscodeConfigSchema),
|
||||
},
|
||||
@@ -185,7 +185,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
||||
'/transcode_configs/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Settings'],
|
||||
tags: ['Settings', 'Transcode Configs'],
|
||||
params: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
@@ -211,6 +211,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
||||
'/transcode_configs/:id/copy',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Settings', 'Transcode Configs'],
|
||||
params: z.object({
|
||||
id: z.uuid(),
|
||||
}),
|
||||
@@ -244,7 +245,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
||||
'/transcode_configs',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Settings'],
|
||||
tags: ['Settings', 'Transcode Configs'],
|
||||
body: TranscodeConfigSchema.omit({
|
||||
id: true,
|
||||
}),
|
||||
@@ -265,7 +266,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
||||
'/transcode_configs/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Settings'],
|
||||
tags: ['Settings', 'Transcode Configs'],
|
||||
body: TranscodeConfigSchema,
|
||||
params: IdPathParamSchema,
|
||||
response: {
|
||||
@@ -286,7 +287,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
||||
'/transcode_configs/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Settings'],
|
||||
tags: ['Settings', 'Transcode Configs'],
|
||||
params: IdPathParamSchema,
|
||||
response: {
|
||||
200: z.void(),
|
||||
|
||||
@@ -196,78 +196,6 @@ export class TranscodeConfigDB implements ITranscodeConfigDB {
|
||||
.limit(1)
|
||||
.execute();
|
||||
});
|
||||
// const numConfigs = await tx
|
||||
// .selectFrom('transcodeConfig')
|
||||
// .select((eb) => eb.fn.count<number>('uuid').as('count'))
|
||||
// .executeTakeFirst()
|
||||
// .then((res) => res?.count ?? 0);
|
||||
|
||||
// // If there are no configs (should be impossible) create a default, assign it to all channels
|
||||
// // and move on.
|
||||
// if (numConfigs === 0) {
|
||||
// const { uuid: newDefaultConfigId } =
|
||||
// await this.insertDefaultConfiguration(tx);
|
||||
// await tx
|
||||
// .updateTable('channel')
|
||||
// .set('transcodeConfigId', newDefaultConfigId)
|
||||
// .execute();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const configToDelete = await tx
|
||||
// .selectFrom('transcodeConfig')
|
||||
// .where('uuid', '=', id)
|
||||
// .selectAll()
|
||||
// .limit(1)
|
||||
// .executeTakeFirst();
|
||||
|
||||
// if (!configToDelete) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // If this is the last config, we'll need a new one and will have to assign it
|
||||
// if (numConfigs === 1) {
|
||||
// const { uuid: newDefaultConfigId } =
|
||||
// await this.insertDefaultConfiguration(tx);
|
||||
// await tx
|
||||
// .updateTable('channel')
|
||||
// .set('transcodeConfigId', newDefaultConfigId)
|
||||
// .execute();
|
||||
// await tx
|
||||
// .deleteFrom('transcodeConfig')
|
||||
// .where('uuid', '=', id)
|
||||
// .limit(1)
|
||||
// .execute();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // We're deleting the default config. Pick a random one to make the new default. Not great!
|
||||
// if (configToDelete.isDefault) {
|
||||
// const newDefaultConfig = await tx
|
||||
// .selectFrom('transcodeConfig')
|
||||
// .where('uuid', '!=', id)
|
||||
// .where('isDefault', '=', 0)
|
||||
// .select('uuid')
|
||||
// .limit(1)
|
||||
// .executeTakeFirstOrThrow();
|
||||
// await tx
|
||||
// .updateTable('transcodeConfig')
|
||||
// .set('isDefault', 1)
|
||||
// .where('uuid', '=', newDefaultConfig.uuid)
|
||||
// .limit(1)
|
||||
// .execute();
|
||||
// await tx
|
||||
// .updateTable('channel')
|
||||
// .set('transcodeConfigId', newDefaultConfig.uuid)
|
||||
// .execute();
|
||||
// }
|
||||
|
||||
// await tx
|
||||
// .deleteFrom('transcodeConfig')
|
||||
// .where('uuid', '=', id)
|
||||
// .limit(1)
|
||||
// .execute();
|
||||
// });
|
||||
}
|
||||
|
||||
private async insertDefaultConfiguration(db: DrizzleDBAccess = this.drizzle) {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { TranscodeConfig } from '@tunarr/types';
|
||||
import { numberToBoolean } from '../../util/sqliteUtil.ts';
|
||||
import type {
|
||||
TranscodeConfig as TranscodeConfigDAO,
|
||||
TranscodeConfigOrm,
|
||||
} from '../schema/TranscodeConfig.ts';
|
||||
import type { TranscodeConfigOrm } from '../schema/TranscodeConfig.ts';
|
||||
|
||||
export function transcodeConfigOrmToDto(
|
||||
config: TranscodeConfigOrm,
|
||||
@@ -11,13 +7,6 @@ export function transcodeConfigOrmToDto(
|
||||
return {
|
||||
...config,
|
||||
id: config.uuid,
|
||||
// disableChannelOverlay: numberToBoolean(config.disableChannelOverlay),
|
||||
// normalizeFrameRate: numberToBoolean(config.normalizeFrameRate),
|
||||
// deinterlaceVideo: numberToBoolean(config.deinterlaceVideo),
|
||||
// isDefault: numberToBoolean(config.isDefault),
|
||||
// disableHardwareDecoder: numberToBoolean(config.disableHardwareDecoder),
|
||||
// disableHardwareEncoding: numberToBoolean(config.disableHardwareEncoding),
|
||||
// disableHardwareFilters: numberToBoolean(config.disableHardwareFilters),
|
||||
disableChannelOverlay: config.disableChannelOverlay ?? false,
|
||||
normalizeFrameRate: config.normalizeFrameRate ?? false,
|
||||
deinterlaceVideo: config.deinterlaceVideo ?? false,
|
||||
@@ -27,19 +16,3 @@ export function transcodeConfigOrmToDto(
|
||||
disableHardwareFilters: config.disableHardwareFilters ?? false,
|
||||
} satisfies TranscodeConfig;
|
||||
}
|
||||
|
||||
export function legacyTranscodeConfigToDto(
|
||||
config: TranscodeConfigDAO,
|
||||
): TranscodeConfig {
|
||||
return {
|
||||
...config,
|
||||
id: config.uuid,
|
||||
disableChannelOverlay: numberToBoolean(config.disableChannelOverlay),
|
||||
normalizeFrameRate: numberToBoolean(config.normalizeFrameRate),
|
||||
deinterlaceVideo: numberToBoolean(config.deinterlaceVideo),
|
||||
isDefault: numberToBoolean(config.isDefault),
|
||||
disableHardwareDecoder: numberToBoolean(config.disableHardwareDecoder),
|
||||
disableHardwareEncoding: numberToBoolean(config.disableHardwareEncoding),
|
||||
disableHardwareFilters: numberToBoolean(config.disableHardwareFilters),
|
||||
} satisfies TranscodeConfig;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Resolution, TupleToUnion } from '@tunarr/types';
|
||||
import { type Resolution, type TupleToUnion } from '@tunarr/types';
|
||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
@@ -146,6 +146,7 @@ export const TranscodeConfig = sqliteTable(
|
||||
audioBufferSize: integer().notNull(),
|
||||
audioSampleRate: integer().notNull(),
|
||||
audioVolumePercent: integer().notNull().default(100), // Default 100
|
||||
// audioLoudnormConfig: text({ mode: 'json' }).$type<LoudnormConfig>(),
|
||||
|
||||
normalizeFrameRate: integer({ mode: 'boolean' }).default(false),
|
||||
deinterlaceVideo: integer({ mode: 'boolean' }).default(true),
|
||||
|
||||
@@ -54,10 +54,6 @@ import {
|
||||
ProgramExternalId,
|
||||
ProgramExternalIdRelations,
|
||||
} from './ProgramExternalId.ts';
|
||||
import {
|
||||
ProgramPlayHistory,
|
||||
ProgramPlayHistoryRelations,
|
||||
} from './ProgramPlayHistory.ts';
|
||||
import {
|
||||
ProgramGrouping,
|
||||
ProgramGroupingRelations,
|
||||
@@ -74,6 +70,10 @@ import {
|
||||
ProgramMediaStream,
|
||||
ProgramMediaStreamRelations,
|
||||
} from './ProgramMediaStream.ts';
|
||||
import {
|
||||
ProgramPlayHistory,
|
||||
ProgramPlayHistoryRelations,
|
||||
} from './ProgramPlayHistory.ts';
|
||||
import {
|
||||
ProgramSubtitles,
|
||||
ProgramSubtitlesRelations,
|
||||
|
||||
@@ -385,6 +385,8 @@ export class FfmpegStreamFactory extends IFFMPEG {
|
||||
// Check if audio and video are coming from same location
|
||||
audioDuration:
|
||||
streamMode === 'hls_direct' ? null : duration.asMilliseconds(),
|
||||
normalizeLoudness: false, // !!this.transcodeConfig.audioLoudnormConfig,
|
||||
// loudnormConfig: this.transcodeConfig.audioLoudnormConfig,
|
||||
});
|
||||
|
||||
let audioInput: AudioInputSource;
|
||||
@@ -411,7 +413,11 @@ export class FfmpegStreamFactory extends IFFMPEG {
|
||||
}
|
||||
|
||||
let watermarkSource: Nullable<WatermarkInputSource> = null;
|
||||
if (streamMode !== ChannelStreamModes.HlsDirect && watermark?.enabled) {
|
||||
if (
|
||||
streamMode !== ChannelStreamModes.HlsDirect &&
|
||||
streamMode !== ChannelStreamModes.HlsDirectV2 &&
|
||||
watermark?.enabled
|
||||
) {
|
||||
const watermarkUrl = watermark.url ?? makeLocalUrl('/images/tunarr.png');
|
||||
watermarkSource = new WatermarkInputSource(
|
||||
new HttpStreamSource(watermarkUrl),
|
||||
|
||||
19
server/src/ffmpeg/builder/filter/LoudnormFilter.ts
Normal file
19
server/src/ffmpeg/builder/filter/LoudnormFilter.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { LoudnormConfig } from '@tunarr/types';
|
||||
import { isDefined } from '../../../util/index.ts';
|
||||
import { FilterOption } from './FilterOption.ts';
|
||||
|
||||
export class LoudnormFilter extends FilterOption {
|
||||
constructor(
|
||||
private loudnormConfig: LoudnormConfig,
|
||||
private sampleRate: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public get filter(): string {
|
||||
const gain = isDefined(this.loudnormConfig.offsetGain)
|
||||
? `:offset=${this.loudnormConfig.offsetGain}`
|
||||
: '';
|
||||
return `loudnorm=I=${this.loudnormConfig.i}:LRA=${this.loudnormConfig.lra}:TP=${this.loudnormConfig.tp}${gain},aresample=${this.sampleRate}`;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FileStreamSource } from '../../../stream/types.ts';
|
||||
import { EmptyFfmpegCapabilities } from '../capabilities/FfmpegCapabilities.ts';
|
||||
import { AudioVolumeFilter } from '../filter/AudioVolumeFilter.ts';
|
||||
import { LoudnormFilter } from '../filter/LoudnormFilter.ts';
|
||||
import { PixelFormatYuv420P } from '../format/PixelFormat.ts';
|
||||
import { AudioInputSource } from '../input/AudioInputSource.ts';
|
||||
import { VideoInputSource } from '../input/VideoInputSource.ts';
|
||||
@@ -127,4 +128,269 @@ describe('BasePipelineBuilder', () => {
|
||||
|
||||
expect(volumeFilter).toBeUndefined();
|
||||
});
|
||||
|
||||
test('add loudnorm filter when loudnormConfig is set', () => {
|
||||
const audio = AudioInputSource.withStream(
|
||||
new FileStreamSource('/path/to/song.flac'),
|
||||
AudioStream.create({
|
||||
channels: 2,
|
||||
codec: 'flac',
|
||||
index: 0,
|
||||
}),
|
||||
AudioState.create({
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 192 * 2,
|
||||
audioChannels: 2,
|
||||
loudnormConfig: { i: -24, lra: 7, tp: -2 },
|
||||
}),
|
||||
);
|
||||
|
||||
const pipeline = new NoopPipelineBuilder(
|
||||
video,
|
||||
audio,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
EmptyFfmpegCapabilities,
|
||||
);
|
||||
|
||||
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||
|
||||
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||
(step) => step instanceof LoudnormFilter,
|
||||
);
|
||||
|
||||
expect(loudnormFilter).toBeDefined();
|
||||
expect(loudnormFilter?.filter).toEqual(
|
||||
'loudnorm=I=-24:LRA=7:TP=-2,aresample=48000',
|
||||
);
|
||||
});
|
||||
|
||||
test('add loudnorm filter with custom offset gain', () => {
|
||||
const audio = AudioInputSource.withStream(
|
||||
new FileStreamSource('/path/to/song.flac'),
|
||||
AudioStream.create({
|
||||
channels: 2,
|
||||
codec: 'flac',
|
||||
index: 0,
|
||||
}),
|
||||
AudioState.create({
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 192 * 2,
|
||||
audioChannels: 2,
|
||||
loudnormConfig: { i: -16, lra: 11, tp: -1, offsetGain: 3 },
|
||||
}),
|
||||
);
|
||||
|
||||
const pipeline = new NoopPipelineBuilder(
|
||||
video,
|
||||
audio,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
EmptyFfmpegCapabilities,
|
||||
);
|
||||
|
||||
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||
|
||||
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||
(step) => step instanceof LoudnormFilter,
|
||||
);
|
||||
|
||||
expect(loudnormFilter).toBeDefined();
|
||||
expect(loudnormFilter?.filter).toEqual(
|
||||
'loudnorm=I=-16:LRA=11:TP=-1:offset=3,aresample=48000',
|
||||
);
|
||||
});
|
||||
|
||||
test('use custom sample rate in loudnorm filter when audioSampleRate is set', () => {
|
||||
const audio = AudioInputSource.withStream(
|
||||
new FileStreamSource('/path/to/song.flac'),
|
||||
AudioStream.create({
|
||||
channels: 2,
|
||||
codec: 'flac',
|
||||
index: 0,
|
||||
}),
|
||||
AudioState.create({
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 192 * 2,
|
||||
audioChannels: 2,
|
||||
audioSampleRate: 44100,
|
||||
loudnormConfig: { i: -24, lra: 7, tp: -2 },
|
||||
}),
|
||||
);
|
||||
|
||||
const pipeline = new NoopPipelineBuilder(
|
||||
video,
|
||||
audio,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
EmptyFfmpegCapabilities,
|
||||
);
|
||||
|
||||
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||
|
||||
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||
(step) => step instanceof LoudnormFilter,
|
||||
);
|
||||
|
||||
expect(loudnormFilter).toBeDefined();
|
||||
expect(loudnormFilter?.filter).toEqual(
|
||||
'loudnorm=I=-24:LRA=7:TP=-2,aresample=44100',
|
||||
);
|
||||
});
|
||||
|
||||
test('do not add loudnorm filter when audio encoder is copy', () => {
|
||||
const audio = AudioInputSource.withStream(
|
||||
new FileStreamSource('/path/to/song.flac'),
|
||||
AudioStream.create({
|
||||
channels: 2,
|
||||
codec: 'flac',
|
||||
index: 0,
|
||||
}),
|
||||
AudioState.create({
|
||||
audioEncoder: 'copy',
|
||||
loudnormConfig: { i: -24, lra: 7, tp: -2 },
|
||||
}),
|
||||
);
|
||||
|
||||
const pipeline = new NoopPipelineBuilder(
|
||||
video,
|
||||
audio,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
EmptyFfmpegCapabilities,
|
||||
);
|
||||
|
||||
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||
|
||||
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||
(step) => step instanceof LoudnormFilter,
|
||||
);
|
||||
|
||||
expect(loudnormFilter).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ desc: 'i too low', config: { i: -70.1, lra: 7, tp: -2 } },
|
||||
{ desc: 'i too high', config: { i: -4.9, lra: 7, tp: -2 } },
|
||||
{ desc: 'lra too low', config: { i: -24, lra: 0.9, tp: -2 } },
|
||||
{ desc: 'lra too high', config: { i: -24, lra: 50.1, tp: -2 } },
|
||||
{ desc: 'tp too low', config: { i: -24, lra: 7, tp: -9.1 } },
|
||||
{ desc: 'tp too high', config: { i: -24, lra: 7, tp: 0.1 } },
|
||||
])(
|
||||
'do not add loudnorm filter when $desc',
|
||||
({ config }) => {
|
||||
const audio = AudioInputSource.withStream(
|
||||
new FileStreamSource('/path/to/song.flac'),
|
||||
AudioStream.create({
|
||||
channels: 2,
|
||||
codec: 'flac',
|
||||
index: 0,
|
||||
}),
|
||||
AudioState.create({
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 192 * 2,
|
||||
audioChannels: 2,
|
||||
loudnormConfig: config,
|
||||
}),
|
||||
);
|
||||
|
||||
const pipeline = new NoopPipelineBuilder(
|
||||
video,
|
||||
audio,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
EmptyFfmpegCapabilities,
|
||||
);
|
||||
|
||||
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||
|
||||
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||
(step) => step instanceof LoudnormFilter,
|
||||
);
|
||||
|
||||
expect(loudnormFilter).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ desc: 'i at lower bound', config: { i: -70, lra: 7, tp: -2 } },
|
||||
{ desc: 'i at upper bound', config: { i: -5, lra: 7, tp: -2 } },
|
||||
{ desc: 'lra at lower bound', config: { i: -24, lra: 1, tp: -2 } },
|
||||
{ desc: 'lra at upper bound', config: { i: -24, lra: 50, tp: -2 } },
|
||||
{ desc: 'tp at lower bound', config: { i: -24, lra: 7, tp: -9 } },
|
||||
{ desc: 'tp at upper bound', config: { i: -24, lra: 7, tp: 0 } },
|
||||
])(
|
||||
'add loudnorm filter when $desc',
|
||||
({ config }) => {
|
||||
const audio = AudioInputSource.withStream(
|
||||
new FileStreamSource('/path/to/song.flac'),
|
||||
AudioStream.create({
|
||||
channels: 2,
|
||||
codec: 'flac',
|
||||
index: 0,
|
||||
}),
|
||||
AudioState.create({
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 192 * 2,
|
||||
audioChannels: 2,
|
||||
loudnormConfig: config,
|
||||
}),
|
||||
);
|
||||
|
||||
const pipeline = new NoopPipelineBuilder(
|
||||
video,
|
||||
audio,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
EmptyFfmpegCapabilities,
|
||||
);
|
||||
|
||||
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||
|
||||
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||
(step) => step instanceof LoudnormFilter,
|
||||
);
|
||||
|
||||
expect(loudnormFilter).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
test('do not add loudnorm filter when loudnormConfig is not set', () => {
|
||||
const audio = AudioInputSource.withStream(
|
||||
new FileStreamSource('/path/to/song.flac'),
|
||||
AudioStream.create({
|
||||
channels: 2,
|
||||
codec: 'flac',
|
||||
index: 0,
|
||||
}),
|
||||
AudioState.create({
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 192 * 2,
|
||||
audioChannels: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const pipeline = new NoopPipelineBuilder(
|
||||
video,
|
||||
audio,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
EmptyFfmpegCapabilities,
|
||||
);
|
||||
|
||||
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||
|
||||
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||
(step) => step instanceof LoudnormFilter,
|
||||
);
|
||||
|
||||
expect(loudnormFilter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ import { Mpeg2VideoEncoder } from '../encoder/Mpeg2VideoEncoder.ts';
|
||||
import { RawVideoEncoder } from '../encoder/RawVideoEncoder.ts';
|
||||
import { AudioVolumeFilter } from '../filter/AudioVolumeFilter.ts';
|
||||
import type { FilterOption } from '../filter/FilterOption.ts';
|
||||
import { LoudnormFilter } from '../filter/LoudnormFilter.ts';
|
||||
import { StreamSeekFilter } from '../filter/StreamSeekFilter.ts';
|
||||
import type { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts';
|
||||
import {
|
||||
@@ -633,7 +634,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
||||
if (
|
||||
!isNull(this.desiredAudioState?.audioVolume) &&
|
||||
this.desiredAudioState.audioVolume !== 100 &&
|
||||
encoder.name != 'copy' &&
|
||||
encoder.name != TranscodeAudioOutputFormat.Copy &&
|
||||
this.desiredAudioState.audioVolume > 0
|
||||
) {
|
||||
this.audioInputSource?.filterSteps?.push(
|
||||
@@ -641,7 +642,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
||||
);
|
||||
}
|
||||
|
||||
if (encoder.name !== 'copy') {
|
||||
if (encoder.name !== TranscodeAudioOutputFormat.Copy) {
|
||||
// This seems to help with audio sync issues in QSV
|
||||
const asyncSamples =
|
||||
this.ffmpegState.decoderHwAccelMode === HardwareAccelerationMode.Qsv
|
||||
@@ -655,6 +656,32 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
||||
this.audioInputSource?.filterSteps.push(new AudioPadFilter());
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isNull(this.desiredAudioState.loudnormConfig) &&
|
||||
encoder.name !== TranscodeAudioOutputFormat.Copy
|
||||
) {
|
||||
if (
|
||||
this.desiredAudioState.loudnormConfig.i < -70.0 ||
|
||||
this.desiredAudioState.loudnormConfig.i > -5.0 ||
|
||||
this.desiredAudioState.loudnormConfig.lra < 1.0 ||
|
||||
this.desiredAudioState.loudnormConfig.lra > 50.0 ||
|
||||
this.desiredAudioState.loudnormConfig.tp < -9.0 ||
|
||||
this.desiredAudioState.loudnormConfig.tp > 0
|
||||
) {
|
||||
this.logger.warn(
|
||||
'Loudnorm config is not valid: %O',
|
||||
this.desiredAudioState.loudnormConfig,
|
||||
);
|
||||
} else {
|
||||
this.audioInputSource?.filterSteps.push(
|
||||
new LoudnormFilter(
|
||||
this.desiredAudioState.loudnormConfig,
|
||||
this.desiredAudioState.audioSampleRate ?? 48_000,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract setupVideoFilters(): void;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExcludeByValueType, Nullable } from '@/types/util.js';
|
||||
import type { LoudnormConfig } from '@tunarr/types';
|
||||
import { isNil, omitBy } from 'lodash-es';
|
||||
import type { AnyFunction } from 'ts-essentials';
|
||||
import type { TranscodeAudioOutputFormat } from '../../../db/schema/TranscodeConfig.ts';
|
||||
@@ -13,6 +14,8 @@ const DefaultAudioState: AudioState = {
|
||||
audioSampleRate: null,
|
||||
audioDuration: null,
|
||||
audioVolume: null,
|
||||
normalizeLoudness: false,
|
||||
loudnormConfig: null,
|
||||
};
|
||||
|
||||
export class AudioState {
|
||||
@@ -23,6 +26,8 @@ export class AudioState {
|
||||
audioSampleRate: Nullable<number>;
|
||||
audioDuration: Nullable<number>;
|
||||
audioVolume: Nullable<number>;
|
||||
normalizeLoudness: boolean;
|
||||
loudnormConfig: Nullable<LoudnormConfig>;
|
||||
|
||||
private constructor(fields: Partial<AudioStateFields> = {}) {
|
||||
const merged: AudioStateFields = {
|
||||
@@ -36,6 +41,8 @@ export class AudioState {
|
||||
this.audioSampleRate = merged.audioSampleRate;
|
||||
this.audioDuration = merged.audioDuration;
|
||||
this.audioVolume = merged.audioVolume;
|
||||
this.normalizeLoudness = merged.normalizeLoudness ?? false;
|
||||
this.loudnormConfig = merged.loudnormConfig;
|
||||
}
|
||||
|
||||
static create(fields: Partial<AudioStateFields> = {}) {
|
||||
|
||||
@@ -196,6 +196,9 @@ export class DirectMigrationProvider implements MigrationProvider {
|
||||
'./sql/0040_daffy_bishop.sql',
|
||||
true,
|
||||
),
|
||||
migration1770236998: makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0041_easy_firebird.sql',
|
||||
),
|
||||
},
|
||||
wrapWithTransaction,
|
||||
),
|
||||
|
||||
1
server/src/migration/db/sql/0041_easy_firebird.sql
Normal file
1
server/src/migration/db/sql/0041_easy_firebird.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `transcode_config` ADD `audio_loudnorm_config` text;
|
||||
3981
server/src/migration/db/sql/meta/0041_snapshot.json
Normal file
3981
server/src/migration/db/sql/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -288,6 +288,13 @@
|
||||
"when": 1769361500423,
|
||||
"tag": "0040_daffy_bishop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1770236977185,
|
||||
"tag": "0041_easy_firebird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,8 +19,8 @@
|
||||
"license": "Zlib",
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "^7.43.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.0.0",
|
||||
"@typescript-eslint/parser": "6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.2",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { z } from 'zod/v4';
|
||||
import type { z } from 'zod';
|
||||
import type {
|
||||
SupportedTranscodeVideoOutputFormats,
|
||||
TranscodeConfigSchema,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type z from 'zod/v4';
|
||||
import type { ExternalId } from './Program.js';
|
||||
import type {
|
||||
HealthCheckSchema,
|
||||
LoudnormConfigSchema,
|
||||
ResolutionSchema,
|
||||
} from './schemas/miscSchemas.js';
|
||||
import type {
|
||||
@@ -28,3 +29,5 @@ export type SingleExternalId = z.infer<typeof SingleExternalIdSchema>;
|
||||
export type MultiExternalId = z.infer<typeof MultiExternalIdSchema>;
|
||||
|
||||
export type HealthCheck = z.infer<typeof HealthCheckSchema>;
|
||||
|
||||
export type LoudnormConfig = z.infer<typeof LoudnormConfigSchema>;
|
||||
|
||||
@@ -12,3 +12,30 @@ export const HealthCheckSchema = z.union([
|
||||
context: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const LoudnormConfigSchema = z.object({
|
||||
i: z.coerce
|
||||
.number()
|
||||
.min(-70.0)
|
||||
.max(-5.0)
|
||||
.default(-24.0)
|
||||
.describe('integrated loudness target'),
|
||||
lra: z.coerce
|
||||
.number()
|
||||
.min(1.0)
|
||||
.max(50.0)
|
||||
.default(7.0)
|
||||
.describe('loudness range target'),
|
||||
tp: z.coerce
|
||||
.number()
|
||||
.min(-9.0)
|
||||
.max(0.0)
|
||||
.default(-2.0)
|
||||
.describe('maximum true peak'),
|
||||
offsetGain: z.coerce
|
||||
.number()
|
||||
.min(-99.0)
|
||||
.max(99.0)
|
||||
.optional()
|
||||
.describe('offset gain to add before peak limiter'),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import z from 'zod/v4';
|
||||
import type { TupleToUnion } from '../util.js';
|
||||
import { ResolutionSchema } from './miscSchemas.js';
|
||||
import { LoudnormConfigSchema, ResolutionSchema } from './miscSchemas.js';
|
||||
import {
|
||||
SupportedErrorAudioTypes,
|
||||
SupportedErrorScreens,
|
||||
@@ -39,8 +39,13 @@ export type SupportedTranscodeAudioOutputFormats = TupleToUnion<
|
||||
|
||||
export const TranscodeConfigSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
threadCount: z.number(),
|
||||
name: z
|
||||
.string({
|
||||
error: (iss) =>
|
||||
iss.input === undefined ? 'Name is required' : 'Invalid input',
|
||||
})
|
||||
.min(1, { error: 'Name cannot be empty' }),
|
||||
threadCount: z.coerce.number(),
|
||||
hardwareAccelerationMode: z.enum(SupportedHardwareAccels),
|
||||
vaapiDriver: z.enum(SupportedVaapiDrivers),
|
||||
vaapiDevice: z.string().nullable(),
|
||||
@@ -49,14 +54,15 @@ export const TranscodeConfigSchema = z.object({
|
||||
videoProfile: z.string().nullable(),
|
||||
videoPreset: z.string().nullable(),
|
||||
videoBitDepth: z.union([z.literal(8), z.literal(10)]).nullable(),
|
||||
videoBitRate: z.number(),
|
||||
videoBufferSize: z.number(),
|
||||
audioChannels: z.number(),
|
||||
videoBitRate: z.coerce.number(),
|
||||
videoBufferSize: z.coerce.number(),
|
||||
audioChannels: z.coerce.number(),
|
||||
audioFormat: z.enum(SupportedTranscodeAudioOutputFormats),
|
||||
audioBitRate: z.number(),
|
||||
audioBufferSize: z.number(),
|
||||
audioSampleRate: z.number(),
|
||||
audioVolumePercent: z.number().default(100),
|
||||
audioBitRate: z.coerce.number(),
|
||||
audioBufferSize: z.coerce.number(),
|
||||
audioSampleRate: z.coerce.number(),
|
||||
audioVolumePercent: z.coerce.number().default(100),
|
||||
audioLoudnormConfig: LoudnormConfigSchema.optional(),
|
||||
normalizeFrameRate: z.boolean(),
|
||||
deinterlaceVideo: z.boolean(),
|
||||
disableChannelOverlay: z.boolean(),
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@hookform/error-message": "^2.0.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mui/icons-material": "^7.0.2",
|
||||
"@mui/material": "^7.0.2",
|
||||
"@mui/x-date-pickers": "^8.4.0",
|
||||
"@tanstack/react-form": "^1.28.0",
|
||||
"@tanstack/react-query": "^5.18.1",
|
||||
"@tanstack/react-query-devtools": "^5.18.1",
|
||||
"@tanstack/react-router": "^1.133.13",
|
||||
@@ -43,7 +45,7 @@
|
||||
"notistack": "^3.0.1",
|
||||
"pluralize": "^8.0.0",
|
||||
"query-string": "^9.1.1",
|
||||
"random-js": "2.1.0",
|
||||
"random-js": "catalog:",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
@@ -62,9 +64,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.80.16",
|
||||
"@tanstack/react-devtools": "^0.9.4",
|
||||
"@tanstack/react-form-devtools": "^0.2.13",
|
||||
"@tanstack/react-router-devtools": "^1.158.1",
|
||||
"@tanstack/react-table": "8.19.3",
|
||||
"@tanstack/router-cli": "^1.35.4",
|
||||
"@tanstack/router-devtools": "1.133.13",
|
||||
"@tanstack/router-vite-plugin": "^1.133.13",
|
||||
"@types/lodash-es": "4.17.9",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
@@ -73,8 +77,8 @@
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||
"@typescript-eslint/parser": "^8.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
|
||||
43
web/src/components/form/BasicCheckboxInput.tsx
Normal file
43
web/src/components/form/BasicCheckboxInput.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { FormControlProps, FormHelperTextProps } from '@mui/material';
|
||||
import {
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
} from '@mui/material';
|
||||
import { isNil } from 'lodash-es';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useFieldContext } from '../../hooks/form.ts';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
formControlProps?: FormControlProps;
|
||||
helperText?: ReactNode;
|
||||
formHelperTextProps?: FormHelperTextProps;
|
||||
};
|
||||
|
||||
export function BasicCheckboxInput({
|
||||
formControlProps,
|
||||
formHelperTextProps,
|
||||
helperText,
|
||||
label,
|
||||
}: Props) {
|
||||
const field = useFieldContext<boolean>();
|
||||
return (
|
||||
<FormControl {...formControlProps}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
value={field.state.value}
|
||||
checked={field.state.value}
|
||||
onChange={(_, checked) => field.handleChange(checked)}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
{!isNil(helperText) && (
|
||||
<FormHelperText {...formHelperTextProps}>{helperText}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
102
web/src/components/form/BasicSelectInput.tsx
Normal file
102
web/src/components/form/BasicSelectInput.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
FormControlProps,
|
||||
FormHelperTextProps,
|
||||
SelectProps,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from '@mui/material';
|
||||
import { isNonEmptyString } from '@tunarr/shared/util';
|
||||
import { identity } from 'lodash-es';
|
||||
import { useMemo } from 'react';
|
||||
import type { StrictOmit } from 'ts-essentials';
|
||||
import type { DropdownOption } from '../../helpers/DropdownOption';
|
||||
import { useFieldContext } from '../../hooks/form.ts';
|
||||
|
||||
export interface Converter<In, Out> {
|
||||
to: (input: In) => Out;
|
||||
from: (out: Out) => In;
|
||||
}
|
||||
|
||||
type Props<FieldTypeT, InputTypeT extends string | number> = {
|
||||
options: DropdownOption<InputTypeT>[] | readonly DropdownOption<InputTypeT>[];
|
||||
converter: Converter<FieldTypeT, InputTypeT>;
|
||||
helperText?: string;
|
||||
selectProps?: SelectProps;
|
||||
formControlProps?: FormControlProps;
|
||||
formHelperTextProps?: FormHelperTextProps;
|
||||
};
|
||||
|
||||
function identityConverter<T>(): Converter<T, T> {
|
||||
return {
|
||||
to: identity,
|
||||
from: identity,
|
||||
};
|
||||
}
|
||||
|
||||
export function BasicSelectInput<ValueTypeT extends string | number>(
|
||||
props: StrictOmit<Props<ValueTypeT, ValueTypeT>, 'converter'>,
|
||||
) {
|
||||
// const field = useFieldContext<ValueTypeT>();
|
||||
// return (
|
||||
// <FormControl {...formControlProps}>
|
||||
// {isNonEmptyString(selectProps?.label) ? (
|
||||
// <InputLabel>{selectProps?.label}</InputLabel>
|
||||
// ) : null}
|
||||
// <Select
|
||||
// {...selectProps}
|
||||
// value={field.state.value}
|
||||
// onChange={(e) => field.handleChange(e.target.value as ValueTypeT)}
|
||||
// >
|
||||
// {options.map((opt) => (
|
||||
// <MenuItem key={opt.value} value={opt.value}>
|
||||
// {opt.description}
|
||||
// </MenuItem>
|
||||
// ))}
|
||||
// </Select>
|
||||
// {isNonEmptyString(helperText) ? (
|
||||
// <FormHelperText {...formHelperTextProps}>{helperText}</FormHelperText>
|
||||
// ) : null}
|
||||
// </FormControl>
|
||||
// );
|
||||
const converter = useMemo(() => identityConverter<ValueTypeT>(), []);
|
||||
return <SelectInput {...props} converter={converter} />;
|
||||
}
|
||||
|
||||
export function SelectInput<ValueTypeT, InputTypeT extends string | number>({
|
||||
options,
|
||||
selectProps,
|
||||
formControlProps,
|
||||
formHelperTextProps,
|
||||
helperText,
|
||||
converter,
|
||||
}: Props<ValueTypeT, InputTypeT>) {
|
||||
const field = useFieldContext<ValueTypeT>();
|
||||
return (
|
||||
<FormControl {...formControlProps}>
|
||||
{isNonEmptyString(selectProps?.label) ? (
|
||||
<InputLabel>{selectProps?.label}</InputLabel>
|
||||
) : null}
|
||||
<Select
|
||||
{...selectProps}
|
||||
value={converter.to(field.state.value)}
|
||||
onChange={(e) =>
|
||||
field.handleChange(converter.from(e.target.value as InputTypeT))
|
||||
}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{isNonEmptyString(helperText) ? (
|
||||
<FormHelperText {...formHelperTextProps}>{helperText}</FormHelperText>
|
||||
) : null}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
43
web/src/components/form/BasicTextInput.tsx
Normal file
43
web/src/components/form/BasicTextInput.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { TextFieldProps } from '@mui/material';
|
||||
import { TextField } from '@mui/material';
|
||||
import type { StandardSchemaV1Issue } from '@tanstack/react-form';
|
||||
import { head, isObject, isString, partition } from 'lodash-es';
|
||||
import { useMemo } from 'react';
|
||||
import { useFieldContext } from '../../hooks/form.ts';
|
||||
|
||||
function isStandardSchemaIssue(err: unknown): err is StandardSchemaV1Issue {
|
||||
return isObject(err) && 'message' in err && isString(err.message);
|
||||
}
|
||||
|
||||
export function BasicTextInput(props: TextFieldProps) {
|
||||
const field = useFieldContext<string>();
|
||||
// console.log(field.state.meta.errors);
|
||||
const errors = useMemo(() => {
|
||||
if (field.state.meta.errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
const [standardIssues, nonStandard] = partition(
|
||||
field.state.meta.errors,
|
||||
isStandardSchemaIssue,
|
||||
);
|
||||
|
||||
const prettyErrors = standardIssues.map((issue) => issue.message);
|
||||
const others = nonStandard.map((s) => JSON.stringify(s));
|
||||
return head(prettyErrors.concat(others));
|
||||
}, [field.state.meta.errors]);
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={() => field.handleBlur()}
|
||||
error={field.state.meta.errors.length > 0}
|
||||
helperText={
|
||||
<>
|
||||
{props.helperText}
|
||||
{errors}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TranscodeConfigSchema } from '@tunarr/types/schemas';
|
||||
import type z from 'zod';
|
||||
|
||||
export type BaseTranscodeConfigProps = {
|
||||
initialConfig: z.input<typeof TranscodeConfigSchema>;
|
||||
showAdvancedSettings?: boolean;
|
||||
};
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useTypedAppFormContext } from '@/hooks/form.ts';
|
||||
import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Link,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import type { TranscodeConfig } from '@tunarr/types';
|
||||
import type { SupportedTranscodeAudioOutputFormats } from '@tunarr/types/schemas';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
LoudnormConfigSchema,
|
||||
type SupportedTranscodeAudioOutputFormats,
|
||||
} from '@tunarr/types/schemas';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
||||
import { NumericFormControllerText } from '../../util/TypedController.tsx';
|
||||
import type { BaseTranscodeConfigProps } from './BaseTranscodeConfigProps.ts';
|
||||
import { useBaseTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||
|
||||
const AudioFormats: DropdownOption<SupportedTranscodeAudioOutputFormats>[] = [
|
||||
{
|
||||
@@ -31,54 +40,105 @@ const AudioFormats: DropdownOption<SupportedTranscodeAudioOutputFormats>[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const TranscodeConfigAudioSettingsForm = () => {
|
||||
const { control, watch } = useFormContext<TranscodeConfig>();
|
||||
const encoder = watch('audioFormat');
|
||||
export const TranscodeConfigAudioSettingsForm = ({
|
||||
initialConfig,
|
||||
showAdvancedSettings,
|
||||
}: BaseTranscodeConfigProps) => {
|
||||
const formOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||
const form = useTypedAppFormContext({ ...formOpts });
|
||||
const [loudnormEnabled, setLoudnormEnabled] = useState(
|
||||
!isNil(form.getFieldValue('audioLoudnormConfig')),
|
||||
);
|
||||
|
||||
const onLoudnormChange = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setLoudnormEnabled(enabled);
|
||||
if (enabled) {
|
||||
form.setFieldValue(
|
||||
'audioLoudnormConfig',
|
||||
LoudnormConfigSchema.decode({}),
|
||||
);
|
||||
} else {
|
||||
form.setFieldValue('audioLoudnormConfig', undefined);
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Audio Format</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
<Stack spacing={2}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<form.AppField
|
||||
name="audioFormat"
|
||||
render={({ field }) => (
|
||||
<Select<SupportedTranscodeAudioOutputFormats>
|
||||
label="Audio Format"
|
||||
{...field}
|
||||
>
|
||||
{AudioFormats.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
children={(field) => (
|
||||
<field.BasicSelectInput
|
||||
formControlProps={{ fullWidth: true }}
|
||||
selectProps={{ label: 'Audio Format' }}
|
||||
options={AudioFormats}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Stack direction={{ sm: 'column', md: 'row' }} gap={2} useFlexGap>
|
||||
<NumericFormControllerText
|
||||
control={control}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<form.Subscribe
|
||||
selector={(s) => s.values.audioFormat}
|
||||
children={(encoder) => (
|
||||
<form.AppField
|
||||
name="audioBitRate"
|
||||
prettyFieldName="Audio Bitrate"
|
||||
TextFieldProps={{
|
||||
id: 'audio-bitrate',
|
||||
label: 'Audio Bitrate',
|
||||
fullWidth: true,
|
||||
disabled: encoder === 'copy',
|
||||
helperText:
|
||||
encoder === 'copy'
|
||||
? 'Bitrate cannot be changed when copying input audio'
|
||||
: null,
|
||||
InputProps: {
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Audio Bitrate"
|
||||
disabled={encoder === 'copy'}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">kbps</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
helperText={
|
||||
encoder === 'copy'
|
||||
? 'Bitrate cannot be changed when copying input audio'
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<NumericFormControllerText
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<form.Subscribe
|
||||
selector={(s) => s.values.audioFormat}
|
||||
children={(encoder) => (
|
||||
<form.AppField
|
||||
name="audioBufferSize"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Audio Buffer Size"
|
||||
disabled={encoder === 'copy'}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">kb</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
helperText={
|
||||
encoder === 'copy'
|
||||
? 'Buffer size cannot be changed when copying input audio'
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <NumericFormControllerText
|
||||
control={control}
|
||||
name="audioBufferSize"
|
||||
prettyFieldName="Audio Buffer Size"
|
||||
@@ -92,41 +152,43 @@ export const TranscodeConfigAudioSettingsForm = () => {
|
||||
? 'Buffer size cannot be changed when copying input audio'
|
||||
: null,
|
||||
InputProps: {
|
||||
endAdornment: <InputAdornment position="end">kb</InputAdornment>,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">kb</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ sm: 'column', md: 'row' }} gap={2} useFlexGap>
|
||||
<NumericFormControllerText
|
||||
control={control}
|
||||
name="audioVolumePercent"
|
||||
prettyFieldName="Audio Volume Percent"
|
||||
TextFieldProps={{
|
||||
id: 'audio-volume',
|
||||
label: 'Audio Volume',
|
||||
fullWidth: true,
|
||||
sx: { my: 1 },
|
||||
helperText: 'Values higher than 100 will boost the audio.',
|
||||
InputProps: {
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<NumericFormControllerText
|
||||
control={control}
|
||||
name="audioChannels"
|
||||
prettyFieldName="Audio Channels"
|
||||
TextFieldProps={{
|
||||
id: 'audio-bitrate',
|
||||
label: 'Audio Channels',
|
||||
fullWidth: true,
|
||||
sx: { my: 1 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
/> */}
|
||||
</Grid>
|
||||
|
||||
<NumericFormControllerText
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<form.Subscribe
|
||||
selector={(s) => s.values.audioFormat}
|
||||
children={(encoder) => (
|
||||
<form.AppField
|
||||
name="audioSampleRate"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Audio Sample Rate"
|
||||
disabled={encoder === 'copy'}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">kHz</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
helperText={
|
||||
encoder === 'copy'
|
||||
? 'Sample rate cannot be changed when copying input audio'
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <NumericFormControllerText
|
||||
control={control}
|
||||
name="audioSampleRate"
|
||||
prettyFieldName="Audio Sample Rate"
|
||||
@@ -139,12 +201,152 @@ export const TranscodeConfigAudioSettingsForm = () => {
|
||||
encoder === 'copy'
|
||||
? 'Sample rate cannot be changed when copying input audio'
|
||||
: null,
|
||||
sx: { my: 1 },
|
||||
InputProps: {
|
||||
endAdornment: <InputAdornment position="end">kHz</InputAdornment>,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">kHz</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/> */}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
{/* <NumericFormControllerText
|
||||
control={control}
|
||||
name="audioChannels"
|
||||
prettyFieldName="Audio Channels"
|
||||
TextFieldProps={{
|
||||
id: 'audio-bitrate',
|
||||
label: 'Audio Channels',
|
||||
fullWidth: true,
|
||||
}}
|
||||
/> */}
|
||||
<form.AppField
|
||||
name="audioChannels"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput fullWidth label="Audio Channels" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
{/* <NumericFormControllerText
|
||||
control={control}
|
||||
name="audioVolumePercent"
|
||||
prettyFieldName="Audio Volume Percent"
|
||||
TextFieldProps={{
|
||||
id: 'audio-volume',
|
||||
label: 'Audio Volume',
|
||||
fullWidth: true,
|
||||
helperText:
|
||||
'Adjust the output volume (not recommended). Values higher than 100 will boost the audio.',
|
||||
InputProps: {
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||
},
|
||||
}}
|
||||
/> */}
|
||||
<form.AppField
|
||||
name="audioVolumePercent"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Audio Volume"
|
||||
helperText={
|
||||
'Adjust the output volume (not recommended). Values higher than 100 will boost the audio.'
|
||||
}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">%</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Audio Loudness Normalization</InputLabel>
|
||||
<Select
|
||||
label="Audio Loudness Normalization"
|
||||
value={loudnormEnabled ? 'enabled' : 'disabled'}
|
||||
onChange={() => onLoudnormChange(!loudnormEnabled)}
|
||||
>
|
||||
<MenuItem value={'disabled'}>Disabled</MenuItem>
|
||||
<MenuItem value={'enabled'}>Enabled (loudnorm)</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Enable{' '}
|
||||
<Link
|
||||
href="https://en.wikipedia.org/wiki/EBU_R_128"
|
||||
target="_blank"
|
||||
>
|
||||
EBU R 128
|
||||
</Link>{' '}
|
||||
loudness normalization via the <code>loudnorm</code> FFmpeg
|
||||
filter. May increase CPU usage during streaming.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{showAdvancedSettings && (
|
||||
<form.Subscribe
|
||||
children={(state) => {
|
||||
const hasOneAdvancedSetting = !!state.values.audioLoudnormConfig;
|
||||
if (!hasOneAdvancedSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Typography component="h6" variant="h6" mb={1}>
|
||||
Advanced Video Options
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Advanced options relating to audio. In general, do not change
|
||||
these unless you know what you are doing!
|
||||
</Typography>
|
||||
{!!state.values.audioLoudnormConfig && (
|
||||
<>
|
||||
<Typography sx={{ mb: 1 }}>Loudnorm Options</Typography>
|
||||
<Stack direction={{ sm: 'column', md: 'row' }} spacing={2}>
|
||||
<form.AppField
|
||||
name="audioLoudnormConfig.i"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Loudness Target"
|
||||
helperText="[-70.0, -5.0]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="audioLoudnormConfig.lra"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Loudness Range Target"
|
||||
helperText="[1.0, 50.0]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="audioLoudnormConfig.tp"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Max True Peak"
|
||||
helperText="[-9.0, 0.0]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,98 +1,69 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from '@mui/material';
|
||||
import type { TranscodeConfig } from '@tunarr/types';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { Grid } from '@mui/material';
|
||||
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
||||
import { useTypedAppFormContext } from '../../../hooks/form.ts';
|
||||
import type { BaseTranscodeConfigProps } from './BaseTranscodeConfigProps.ts';
|
||||
import { useBaseTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||
|
||||
const supportedErrorScreens = [
|
||||
{
|
||||
value: 'pic',
|
||||
string: 'Default Generic Error Image',
|
||||
description: 'Default Generic Error Image',
|
||||
},
|
||||
{ value: 'blank', string: 'Blank Screen' },
|
||||
{ value: 'static', string: 'Static' },
|
||||
{ value: 'blank', description: 'Blank Screen' },
|
||||
{ value: 'static', description: 'Static' },
|
||||
{
|
||||
value: 'testsrc',
|
||||
string: 'Test Pattern (color bars + timer)',
|
||||
description: 'Test Pattern (color bars + timer)',
|
||||
},
|
||||
{
|
||||
value: 'text',
|
||||
string: 'Detailed error (requires ffmpeg with drawtext)',
|
||||
description: 'Detailed error (requires ffmpeg with drawtext)',
|
||||
},
|
||||
{
|
||||
value: 'kill',
|
||||
string: 'Stop stream, show errors in logs',
|
||||
description: 'Stop stream, show errors in logs',
|
||||
},
|
||||
];
|
||||
] satisfies DropdownOption<string>[];
|
||||
|
||||
const supportedErrorAudio = [
|
||||
{ value: 'whitenoise', string: 'White Noise' },
|
||||
{ value: 'sine', string: 'Beep' },
|
||||
{ value: 'silent', string: 'No Audio' },
|
||||
];
|
||||
|
||||
export const TranscodeConfigErrorOptions = () => {
|
||||
const { control } = useFormContext<TranscodeConfig>();
|
||||
{ value: 'whitenoise', description: 'White Noise' },
|
||||
{ value: 'sine', description: 'Beep' },
|
||||
{ value: 'silent', description: 'No Audio' },
|
||||
] satisfies DropdownOption<string>[];
|
||||
|
||||
export const TranscodeConfigErrorOptions = ({
|
||||
initialConfig,
|
||||
}: BaseTranscodeConfigProps) => {
|
||||
const formOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||
const form = useTypedAppFormContext({ ...formOpts });
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ sm: 12, md: 6 }}>
|
||||
<FormControl sx={{ mt: 2 }}>
|
||||
<InputLabel id="error-screen-label">Error Screen</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
<form.AppField
|
||||
name="errorScreen"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
labelId="error-screen-label"
|
||||
id="error-screen"
|
||||
label="Error Screen"
|
||||
{...field}
|
||||
>
|
||||
{supportedErrorScreens.map((error) => (
|
||||
<MenuItem key={error.value} value={error.value}>
|
||||
{error.string}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
children={(field) => (
|
||||
<field.BasicSelectInput
|
||||
formControlProps={{ sx: { mt: 2 }, fullWidth: true }}
|
||||
options={supportedErrorScreens}
|
||||
selectProps={{ label: 'Error Screen' }}
|
||||
helperText="If there are issues playing a video, Tunarr will try to use an error
|
||||
screen as a placeholder while retrying loading the video every 60
|
||||
seconds."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormHelperText>
|
||||
If there are issues playing a video, Tunarr will try to use an error
|
||||
screen as a placeholder while retrying loading the video every 60
|
||||
seconds.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ sm: 12, md: 6 }}>
|
||||
<FormControl sx={{ mt: 2 }} fullWidth>
|
||||
<InputLabel id="error-audio-label">Error Audio</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
<form.AppField
|
||||
name="errorScreenAudio"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
labelId="error-audio-label"
|
||||
id="error-screen"
|
||||
label="Error Audio"
|
||||
fullWidth
|
||||
{...field}
|
||||
>
|
||||
{supportedErrorAudio.map((error) => (
|
||||
<MenuItem key={error.value} value={error.value}>
|
||||
{error.string}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
children={(field) => (
|
||||
<field.BasicSelectInput
|
||||
formControlProps={{ sx: { mt: 2 }, fullWidth: true }}
|
||||
options={supportedErrorAudio}
|
||||
selectProps={{ label: 'Error Audio', fullWidth: true }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
CheckboxFormController,
|
||||
NumericFormControllerText,
|
||||
} from '@/components/util/TypedController';
|
||||
import { isNonEmptyString } from '@/helpers/util';
|
||||
import { useAppForm } from '@/hooks/form.ts';
|
||||
import { Check } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
@@ -14,83 +12,83 @@ import {
|
||||
Link as MuiLink,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import type { TranscodeConfig } from '@tunarr/types';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import type { TranscodeConfigSchema } from '@tunarr/types/schemas';
|
||||
import type z from 'zod';
|
||||
import useStore from '../../../store/index.ts';
|
||||
import { setShowAdvancedSettings } from '../../../store/settings/actions.ts';
|
||||
import Breadcrumbs from '../../Breadcrumbs.tsx';
|
||||
import { TranscodeConfigAdvancedOptions } from './TranscodeConfigAdvancedOptions.tsx';
|
||||
import { TranscodeConfigAudioSettingsForm } from './TranscodeConfigAudioSettingsForm.tsx';
|
||||
import { TranscodeConfigErrorOptions } from './TranscodeConfigErrorOptions.tsx';
|
||||
import { TranscodeConfigVideoSettingsForm } from './TranscodeConfigVideoSettingsForm.tsx';
|
||||
import { useTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||
|
||||
type Props = {
|
||||
onSave: (config: TranscodeConfig) => Promise<TranscodeConfig>;
|
||||
initialConfig: TranscodeConfig;
|
||||
initialConfig: z.input<typeof TranscodeConfigSchema>;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
export const TranscodeConfigSettingsForm = ({
|
||||
onSave,
|
||||
initialConfig,
|
||||
isNew,
|
||||
}: Props) => {
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const transcodeConfigForm = useForm<TranscodeConfig>({
|
||||
defaultValues: initialConfig,
|
||||
mode: 'onChange',
|
||||
});
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting, isValid, isDirty },
|
||||
handleSubmit,
|
||||
watch,
|
||||
} = transcodeConfigForm;
|
||||
|
||||
const hardwareAccelerationMode = watch('hardwareAccelerationMode');
|
||||
|
||||
const saveForm = async (data: TranscodeConfig) => {
|
||||
try {
|
||||
const newConfig = await onSave(data);
|
||||
reset(newConfig);
|
||||
snackbar.enqueueSnackbar('Successfully saved config!', {
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
snackbar.enqueueSnackbar(
|
||||
'Error while saving transcode config. See console log for details.',
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
const showAdvancedSettings = useStore(
|
||||
(s) => s.settings.ui.showAdvancedSettings,
|
||||
);
|
||||
}
|
||||
|
||||
const saveForm = (newConfig: TranscodeConfig) => {
|
||||
transcodeConfigForm.reset(newConfig, { keepDefaultValues: true });
|
||||
};
|
||||
|
||||
const handleSubmitError = (errors: FieldErrors<TranscodeConfig>) => {
|
||||
console.error(errors);
|
||||
};
|
||||
const formOpts = useTranscodeConfigFormOptions({
|
||||
initialConfig,
|
||||
isNew,
|
||||
onSave: saveForm,
|
||||
});
|
||||
const transcodeConfigForm = useAppForm({ ...formOpts });
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit(saveForm, handleSubmitError)}>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
transcodeConfigForm.handleSubmit().catch(console.error);
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs />
|
||||
<FormProvider {...transcodeConfigForm}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h5">
|
||||
Edit Config: "{initialConfig.name}"
|
||||
<transcodeConfigForm.AppForm>
|
||||
<Stack spacing={2} divider={<Divider />}>
|
||||
<Stack direction={'row'}>
|
||||
<Typography variant="h4">
|
||||
Edit Transcode Config: "{initialConfig.name}"
|
||||
</Typography>
|
||||
<Divider />
|
||||
<ToggleButton
|
||||
value={showAdvancedSettings}
|
||||
selected={showAdvancedSettings}
|
||||
onChange={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
<Check sx={{ mr: 0.5 }} /> Show Advanced
|
||||
</ToggleButton>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
General
|
||||
</Typography>
|
||||
<Grid container columnSpacing={2}>
|
||||
<Grid size={{ sm: 12, md: 6 }}>
|
||||
<Controller
|
||||
<transcodeConfigForm.AppField
|
||||
name="name"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput fullWidth label="Name" />
|
||||
)}
|
||||
/>
|
||||
{/* <Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
@@ -114,10 +112,37 @@ export const TranscodeConfigSettingsForm = ({
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
/> */}
|
||||
</Grid>
|
||||
<Grid size={{ sm: 12, md: 6 }}>
|
||||
<NumericFormControllerText
|
||||
<transcodeConfigForm.Field
|
||||
name="threadCount"
|
||||
children={(field) => (
|
||||
<TextField
|
||||
label="Threads"
|
||||
fullWidth
|
||||
helperText={
|
||||
<>
|
||||
Sets the number of threads used to decode the input
|
||||
stream. Set to 0 to let ffmpeg automatically decide
|
||||
how many threads to use. Read more about this option{' '}
|
||||
<MuiLink
|
||||
target="_blank"
|
||||
href="https://ffmpeg.org/ffmpeg-codecs.html#:~:text=threads%20integer%20(decoding/encoding%2Cvideo)"
|
||||
>
|
||||
here
|
||||
</MuiLink>
|
||||
. <strong>Note: </strong> this option is overridden to
|
||||
1 when using hardware accelearation for stability
|
||||
reasons.
|
||||
</>
|
||||
}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <NumericFormControllerText
|
||||
control={control}
|
||||
name="threadCount"
|
||||
prettyFieldName="Threads"
|
||||
@@ -135,19 +160,35 @@ export const TranscodeConfigSettingsForm = ({
|
||||
>
|
||||
here
|
||||
</MuiLink>
|
||||
. <strong>Note: </strong> this option is overridden to 1
|
||||
when using hardware accelearation for stability reasons.
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<CheckboxFormController
|
||||
control={control}
|
||||
<transcodeConfigForm.Field
|
||||
name="disableChannelOverlay"
|
||||
children={(field) => (
|
||||
<Checkbox
|
||||
// {...field}
|
||||
value={field.state.value}
|
||||
checked={field.state.value}
|
||||
onChange={(_, checked) =>
|
||||
field.handleChange(checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
// <CheckboxFormController
|
||||
// control={control}
|
||||
// name="disableChannelOverlay"
|
||||
// />
|
||||
}
|
||||
label={'Disable Watermarks'}
|
||||
/>
|
||||
@@ -159,72 +200,78 @@ export const TranscodeConfigSettingsForm = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
<Divider />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ sm: 12, md: 6 }}>
|
||||
<Typography component="h6" variant="h6" sx={{ mb: 2 }}>
|
||||
<Box>
|
||||
<Typography component="h5" variant="h5" sx={{ mb: 2 }}>
|
||||
Video Options
|
||||
</Typography>
|
||||
<TranscodeConfigVideoSettingsForm />
|
||||
</Grid>
|
||||
<Grid size={{ sm: 12, md: 6 }}>
|
||||
<Typography component="h6" variant="h6" sx={{ mb: 2 }}>
|
||||
Audio Options
|
||||
</Typography>
|
||||
<TranscodeConfigAudioSettingsForm />
|
||||
</Grid>
|
||||
<Grid size={12} sx={{ mt: 2 }}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
{hardwareAccelerationMode !== 'none' && (
|
||||
<>
|
||||
<Grid size={{ sm: 12 }}>
|
||||
<TranscodeConfigVideoSettingsForm initialConfig={initialConfig} />
|
||||
<transcodeConfigForm.Subscribe
|
||||
selector={(s) => s.values.hardwareAccelerationMode}
|
||||
children={(hardwareAccelerationMode) =>
|
||||
showAdvancedSettings &&
|
||||
hardwareAccelerationMode !== 'none' && (
|
||||
<Box>
|
||||
<Typography component="h6" variant="h6" mb={1}>
|
||||
Advanced Options
|
||||
Advanced Video Options
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Advanced options relating to transcoding. In general, do not
|
||||
change these unless you know what you are doing! These
|
||||
Advanced options relating to transcoding. In general, do
|
||||
not change these unless you know what you are doing! These
|
||||
settings exist in order to leave some parity with the old
|
||||
dizqueTV transcode pipeline as well as to provide mechanisms
|
||||
to aid in debugging streaming issues.
|
||||
dizqueTV transcode pipeline as well as to provide
|
||||
mechanisms to aid in debugging streaming issues.
|
||||
</Typography>
|
||||
<TranscodeConfigAdvancedOptions />
|
||||
</Grid>
|
||||
<Grid size={12} sx={{ mt: 2 }}>
|
||||
<Divider />
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<Grid size={12}>
|
||||
<Typography component="h6" variant="h6" sx={{ pt: 2, pb: 1 }}>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography component="h5" variant="h5" sx={{ mb: 2 }}>
|
||||
Audio Options
|
||||
</Typography>
|
||||
<TranscodeConfigAudioSettingsForm
|
||||
initialConfig={initialConfig}
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography component="h6" variant="h6" sx={{ pb: 1 }}>
|
||||
Error Options
|
||||
</Typography>
|
||||
<TranscodeConfigErrorOptions />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<TranscodeConfigErrorOptions initialConfig={initialConfig} />
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2} direction="row" justifyContent="right">
|
||||
{(isDirty || (isDirty && !isSubmitting)) && (
|
||||
<transcodeConfigForm.Subscribe
|
||||
selector={(state) => state}
|
||||
children={({ isPristine, canSubmit, isSubmitting }) => (
|
||||
<>
|
||||
{!isPristine ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
reset();
|
||||
transcodeConfigForm.reset();
|
||||
}}
|
||||
>
|
||||
Reset Changes
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isValid || isSubmitting || (!isDirty && !isNew)}
|
||||
disabled={!canSubmit}
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</transcodeConfigForm.AppForm>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import type { SelectChangeEvent } from '@mui/material';
|
||||
import {
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Link as MuiLink,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { useTypedAppFormContext } from '@/hooks/form.ts';
|
||||
import { InputAdornment, Link as MuiLink, Stack } from '@mui/material';
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import type {
|
||||
Resolution,
|
||||
SupportedTranscodeVideoOutputFormat,
|
||||
TranscodeConfig,
|
||||
} from '@tunarr/types';
|
||||
import type { SupportedHardwareAccels } from '@tunarr/types/schemas';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useMemo } from 'react';
|
||||
import { getApiFfmpegInfoOptions } from '../../../generated/@tanstack/react-query.gen.ts';
|
||||
import { TranscodeResolutionOptions } from '../../../helpers/constants.ts';
|
||||
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
||||
@@ -25,11 +15,10 @@ import {
|
||||
resolutionFromAnyString,
|
||||
resolutionToString,
|
||||
} from '../../../helpers/util.ts';
|
||||
import {
|
||||
CheckboxFormController,
|
||||
NumericFormControllerText,
|
||||
TypedController,
|
||||
} from '../../util/TypedController.tsx';
|
||||
|
||||
import type { Converter } from '../../form/BasicSelectInput.tsx';
|
||||
import type { BaseTranscodeConfigProps } from './BaseTranscodeConfigProps.ts';
|
||||
import { useBaseTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||
|
||||
const VideoFormats: DropdownOption<SupportedTranscodeVideoOutputFormat>[] = [
|
||||
{
|
||||
@@ -70,35 +59,55 @@ const VideoHardwareAccelerationOptions: DropdownOption<SupportedHardwareAccels>[
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const TranscodeConfigVideoSettingsForm = () => {
|
||||
const resolutionConverter: Converter<Resolution, string> = {
|
||||
to: (res) => resolutionToString(res),
|
||||
from: (str) => resolutionFromAnyString(str)!,
|
||||
};
|
||||
|
||||
export const TranscodeConfigVideoSettingsForm = ({
|
||||
initialConfig,
|
||||
}: BaseTranscodeConfigProps) => {
|
||||
const ffmpegInfo = useSuspenseQuery({
|
||||
...getApiFfmpegInfoOptions(),
|
||||
});
|
||||
|
||||
const { control, watch } = useFormContext<TranscodeConfig>();
|
||||
const formOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||
const form = useTypedAppFormContext({ ...formOpts });
|
||||
|
||||
const hardwareAccelerationMode = watch('hardwareAccelerationMode');
|
||||
const hardwareAccelerationMode = useStore(
|
||||
form.store,
|
||||
(state) => state.values.hardwareAccelerationMode,
|
||||
); // watch('hardwareAccelerationMode');
|
||||
|
||||
const hardwareAccelerationOptions = useMemo(() => {
|
||||
return VideoHardwareAccelerationOptions.filter(
|
||||
({ value }) =>
|
||||
value === 'none' ||
|
||||
ffmpegInfo.data.hardwareAccelerationTypes.includes(value),
|
||||
);
|
||||
}, [ffmpegInfo.data.hardwareAccelerationTypes]);
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Video Format</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
<Stack spacing={2}>
|
||||
<form.AppField
|
||||
name="videoFormat"
|
||||
render={({ field }) => (
|
||||
<Select label="Video Format" {...field}>
|
||||
{VideoFormats.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
children={(field) => (
|
||||
<field.BasicSelectInput
|
||||
selectProps={{ label: 'Video Format' }}
|
||||
options={VideoFormats}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormHelperText></FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<form.AppField
|
||||
name="hardwareAccelerationMode"
|
||||
children={(field) => (
|
||||
<field.BasicSelectInput
|
||||
options={hardwareAccelerationOptions}
|
||||
selectProps={{ label: 'Hardware Acceleration' }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <FormControl fullWidth>
|
||||
<InputLabel>Hardware Acceleration</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -118,9 +127,33 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
||||
)}
|
||||
/>
|
||||
<FormHelperText></FormHelperText>
|
||||
</FormControl>
|
||||
</FormControl> */}
|
||||
<form.Subscribe
|
||||
selector={(s) => s.values.hardwareAccelerationMode}
|
||||
children={(hwAccel) =>
|
||||
(hwAccel === 'qsv' || hwAccel === 'vaapi') && (
|
||||
<form.AppField
|
||||
name="vaapiDevice"
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label={hwAccel === 'qsv' ? 'QSV Device' : 'VA-API Device'}
|
||||
helperText={
|
||||
<span>
|
||||
Override the default{' '}
|
||||
{hardwareAccelerationMode === 'qsv' ? 'QSV' : 'VA-API'}{' '}
|
||||
device path (defaults to <code>/dev/dri/renderD128</code>{' '}
|
||||
on Linux and blank otherwise)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{(hardwareAccelerationMode === 'vaapi' ||
|
||||
{/* {(hardwareAccelerationMode === 'vaapi' ||
|
||||
hardwareAccelerationMode === 'qsv') && (
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -145,66 +178,52 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="target-resolution-label">Resolution</InputLabel>
|
||||
<TypedController
|
||||
control={control}
|
||||
)} */}
|
||||
<form.AppField
|
||||
name="resolution"
|
||||
toFormType={resolutionFromAnyString}
|
||||
valueExtractor={(e) => (e as SelectChangeEvent).target.value}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
labelId="target-resolution-label"
|
||||
id="target-resolution"
|
||||
label="Resolution"
|
||||
{...field}
|
||||
value={resolutionToString(field.value)}
|
||||
>
|
||||
{TranscodeResolutionOptions.map((resolution) => (
|
||||
<MenuItem key={resolution.value} value={resolution.value}>
|
||||
{resolution.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
children={(field) => (
|
||||
<field.SelectInput
|
||||
options={TranscodeResolutionOptions}
|
||||
converter={resolutionConverter}
|
||||
selectProps={{ label: 'Resolution' }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Stack direction={{ sm: 'column', md: 'row' }} gap={2} useFlexGap>
|
||||
<NumericFormControllerText
|
||||
control={control}
|
||||
<Stack direction={{ sm: 'column', md: 'row' }} spacing={2} useFlexGap>
|
||||
<form.AppField
|
||||
name="videoBitRate"
|
||||
prettyFieldName="Video Bitrate"
|
||||
TextFieldProps={{
|
||||
id: 'video-bitrate',
|
||||
label: 'Video Bitrate',
|
||||
fullWidth: true,
|
||||
sx: { my: 1 },
|
||||
InputProps: {
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Video Bitrate"
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">kbps</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<NumericFormControllerText
|
||||
control={control}
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="videoBufferSize"
|
||||
prettyFieldName="Video Buffer Size"
|
||||
TextFieldProps={{
|
||||
id: 'video-buffer-size',
|
||||
label: 'Video Buffer Size',
|
||||
fullWidth: true,
|
||||
sx: { my: 1 },
|
||||
InputProps: {
|
||||
endAdornment: <InputAdornment position="end">kb</InputAdornment>,
|
||||
children={(field) => (
|
||||
<field.BasicTextInput
|
||||
fullWidth
|
||||
label="Video Buffer Size"
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">kb</InputAdornment>
|
||||
),
|
||||
},
|
||||
helperText: (
|
||||
}}
|
||||
helperText={
|
||||
<>
|
||||
Buffer size effects how frequently ffmpeg reconsiders the output
|
||||
bitrate.{' '}
|
||||
Buffer size effects how frequently ffmpeg reconsiders the
|
||||
output bitrate.{' '}
|
||||
<MuiLink
|
||||
target="_blank"
|
||||
href="https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate#Whatdoes-bufsizedo"
|
||||
@@ -212,11 +231,35 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
||||
Read more
|
||||
</MuiLink>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Stack gap={1} direction={{ sm: 'column', md: 'row' }}>
|
||||
<form.AppField
|
||||
name="deinterlaceVideo"
|
||||
children={(field) => (
|
||||
<field.BasicCheckboxInput
|
||||
label="Auto Deinterlace Video"
|
||||
formControlProps={{ fullWidth: true }}
|
||||
helperText="If set, all watermark overlays will be disabled for channels assigned this transcode config."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name="normalizeFrameRate"
|
||||
children={(field) => (
|
||||
<field.BasicCheckboxInput
|
||||
label="Normalize Frame Rate"
|
||||
formControlProps={{ fullWidth: true }}
|
||||
helperText="Output video at a constant frame rate."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
{/*
|
||||
<Stack gap={1} direction={{ sm: 'column', md: 'row' }}>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -227,7 +270,7 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
||||
}
|
||||
label={'Auto Deinterlace Video'}
|
||||
/>
|
||||
<FormHelperText></FormHelperText>
|
||||
<FormHelperText> </FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
@@ -243,7 +286,7 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
||||
Output video at a constant frame rate.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Stack> */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -153,7 +153,6 @@ export const TranscodeConfigsTable = () => {
|
||||
visibleInShowHideMenu: false,
|
||||
},
|
||||
},
|
||||
positionActionsColumn: 'last',
|
||||
renderTopToolbarCustomActions() {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" gap={2} useFlexGap>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { formOptions } from '@tanstack/react-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { TranscodeConfig } from '@tunarr/types';
|
||||
import { TranscodeConfigSchema } from '@tunarr/types/schemas';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import type z from 'zod';
|
||||
import {
|
||||
getApiTranscodeConfigsQueryKey,
|
||||
postApiTranscodeConfigsMutation,
|
||||
putApiTranscodeConfigsByIdMutation,
|
||||
} from '../../../generated/@tanstack/react-query.gen.ts';
|
||||
|
||||
type Opts = {
|
||||
initialConfig: z.input<typeof TranscodeConfigSchema>;
|
||||
isNew?: boolean;
|
||||
onSave: (newConfig: TranscodeConfig) => void;
|
||||
};
|
||||
|
||||
export const useBaseTranscodeConfigFormOptions = (
|
||||
initialConfig: z.input<typeof TranscodeConfigSchema>,
|
||||
) => {
|
||||
return formOptions({
|
||||
defaultValues: initialConfig,
|
||||
// mode: 'onChange',
|
||||
validators: {
|
||||
onChange: TranscodeConfigSchema,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTranscodeConfigFormOptions = ({
|
||||
initialConfig,
|
||||
onSave,
|
||||
isNew,
|
||||
}: Opts) => {
|
||||
const snackbar = useSnackbar();
|
||||
const baseOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
...putApiTranscodeConfigsByIdMutation(),
|
||||
onSuccess: (ret) => {
|
||||
snackbar.enqueueSnackbar('Successfully saved config!', {
|
||||
variant: 'success',
|
||||
});
|
||||
onSave(ret);
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: getApiTranscodeConfigsQueryKey(),
|
||||
exact: false,
|
||||
});
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
snackbar.enqueueSnackbar(
|
||||
'Error while saving transcode config. See console log for details.',
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const newConfigMutation = useMutation({
|
||||
...postApiTranscodeConfigsMutation(),
|
||||
onSuccess: (ret) => {
|
||||
snackbar.enqueueSnackbar('Successfully saved config!', {
|
||||
variant: 'success',
|
||||
});
|
||||
onSave(ret);
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: getApiTranscodeConfigsQueryKey(),
|
||||
exact: false,
|
||||
});
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
snackbar.enqueueSnackbar(
|
||||
'Error while saving transcode config. See console log for details.',
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return formOptions({
|
||||
...baseOpts,
|
||||
onSubmit: (config) => {
|
||||
const parsedConfig = TranscodeConfigSchema.parse(config.value);
|
||||
if (isNew) {
|
||||
newConfigMutation.mutate({ body: parsedConfig });
|
||||
} else {
|
||||
updateConfigMutation.mutate({
|
||||
path: { id: initialConfig.id },
|
||||
body: parsedConfig,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -318,12 +318,17 @@ export const NumericFormControllerText = <
|
||||
},
|
||||
);
|
||||
|
||||
let formValue = displayValue ?? field.value;
|
||||
if (props.float) {
|
||||
formValue = parseFloat(field.value)?.toFixed(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
error={!isNil(fieldError)}
|
||||
{...field}
|
||||
{...fieldProps}
|
||||
value={displayValue ?? field.value}
|
||||
value={formValue}
|
||||
helperText={helperText}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,16 @@ import React from 'react';
|
||||
export const TanStackRouterDevtools = import.meta.env.PROD
|
||||
? () => null // Render nothing in production
|
||||
: React.lazy(async () => {
|
||||
// const TanStackDevtools = (await import('@tanstack/react-devtools'))
|
||||
// .TanStackDevtools;
|
||||
const TanStackRouterDevtoolsComponent = (
|
||||
await import('@tanstack/router-devtools')
|
||||
await import('@tanstack/react-router-devtools')
|
||||
).TanStackRouterDevtools;
|
||||
const TanStackQueryDevtoolsComponent = (
|
||||
await import('@tanstack/react-query-devtools')
|
||||
).ReactQueryDevtools;
|
||||
// const formDevtoolsPlugin = (await import('@tanstack/react-form-devtools'))
|
||||
// .formDevtoolsPlugin;
|
||||
// Lazy load in development
|
||||
return {
|
||||
default: () => (
|
||||
@@ -21,6 +25,10 @@ export const TanStackRouterDevtools = import.meta.env.PROD
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
{/* <TanStackDevtools
|
||||
plugins={[formDevtoolsPlugin()]}
|
||||
eventBusConfig={{ debug: true }}
|
||||
/> */}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -114,11 +114,11 @@ export const getChannelsOptions = (options?: Options<GetChannelsData>) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const createChannelV2QueryKey = (options: Options<CreateChannelV2Data>) => createQueryKey('createChannelV2', options, false, [
|
||||
export const createChannelV2QueryKey = (options?: Options<CreateChannelV2Data>) => createQueryKey('createChannelV2', options, false, [
|
||||
'Channels'
|
||||
]);
|
||||
|
||||
export const createChannelV2Options = (options: Options<CreateChannelV2Data>) => {
|
||||
export const createChannelV2Options = (options?: Options<CreateChannelV2Data>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await createChannelV2({
|
||||
@@ -1822,11 +1822,11 @@ export const getApiMediaSourcesOptions = (options?: Options<GetApiMediaSourcesDa
|
||||
});
|
||||
};
|
||||
|
||||
export const postApiMediaSourcesQueryKey = (options: Options<PostApiMediaSourcesData>) => createQueryKey('postApiMediaSources', options, false, [
|
||||
export const postApiMediaSourcesQueryKey = (options?: Options<PostApiMediaSourcesData>) => createQueryKey('postApiMediaSources', options, false, [
|
||||
'Media Source'
|
||||
]);
|
||||
|
||||
export const postApiMediaSourcesOptions = (options: Options<PostApiMediaSourcesData>) => {
|
||||
export const postApiMediaSourcesOptions = (options?: Options<PostApiMediaSourcesData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await postApiMediaSources({
|
||||
@@ -2208,7 +2208,8 @@ export const putApiFfmpegSettingsMutation = (options?: Partial<Options<PutApiFfm
|
||||
};
|
||||
|
||||
export const getApiTranscodeConfigsQueryKey = (options?: Options<GetApiTranscodeConfigsData>) => createQueryKey('getApiTranscodeConfigs', options, false, [
|
||||
'Settings'
|
||||
'Settings',
|
||||
'Transcode Configs'
|
||||
]);
|
||||
|
||||
export const getApiTranscodeConfigsOptions = (options?: Options<GetApiTranscodeConfigsData>) => {
|
||||
@@ -2227,7 +2228,8 @@ export const getApiTranscodeConfigsOptions = (options?: Options<GetApiTranscodeC
|
||||
};
|
||||
|
||||
export const postApiTranscodeConfigsQueryKey = (options: Options<PostApiTranscodeConfigsData>) => createQueryKey('postApiTranscodeConfigs', options, false, [
|
||||
'Settings'
|
||||
'Settings',
|
||||
'Transcode Configs'
|
||||
]);
|
||||
|
||||
export const postApiTranscodeConfigsOptions = (options: Options<PostApiTranscodeConfigsData>) => {
|
||||
@@ -2274,7 +2276,8 @@ export const deleteApiTranscodeConfigsByIdMutation = (options?: Partial<Options<
|
||||
};
|
||||
|
||||
export const getApiTranscodeConfigsByIdQueryKey = (options: Options<GetApiTranscodeConfigsByIdData>) => createQueryKey('getApiTranscodeConfigsById', options, false, [
|
||||
'Settings'
|
||||
'Settings',
|
||||
'Transcode Configs'
|
||||
]);
|
||||
|
||||
export const getApiTranscodeConfigsByIdOptions = (options: Options<GetApiTranscodeConfigsByIdData>) => {
|
||||
@@ -2306,7 +2309,10 @@ export const putApiTranscodeConfigsByIdMutation = (options?: Partial<Options<Put
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const postApiTranscodeConfigsByIdCopyQueryKey = (options: Options<PostApiTranscodeConfigsByIdCopyData>) => createQueryKey('postApiTranscodeConfigsByIdCopy', options);
|
||||
export const postApiTranscodeConfigsByIdCopyQueryKey = (options: Options<PostApiTranscodeConfigsByIdCopyData>) => createQueryKey('postApiTranscodeConfigsByIdCopy', options, false, [
|
||||
'Settings',
|
||||
'Transcode Configs'
|
||||
]);
|
||||
|
||||
export const postApiTranscodeConfigsByIdCopyOptions = (options: Options<PostApiTranscodeConfigsByIdCopyData>) => {
|
||||
return queryOptions({
|
||||
|
||||
@@ -46,14 +46,14 @@ export const getChannels = <ThrowOnError extends boolean = false>(options?: Opti
|
||||
});
|
||||
};
|
||||
|
||||
export const createChannelV2 = <ThrowOnError extends boolean = false>(options: Options<CreateChannelV2Data, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<CreateChannelV2Responses, CreateChannelV2Errors, ThrowOnError>({
|
||||
export const createChannelV2 = <ThrowOnError extends boolean = false>(options?: Options<CreateChannelV2Data, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).post<CreateChannelV2Responses, CreateChannelV2Errors, ThrowOnError>({
|
||||
responseType: 'json',
|
||||
url: '/api/channels',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -650,14 +650,14 @@ export const getApiMediaSources = <ThrowOnError extends boolean = false>(options
|
||||
});
|
||||
};
|
||||
|
||||
export const postApiMediaSources = <ThrowOnError extends boolean = false>(options: Options<PostApiMediaSourcesData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<PostApiMediaSourcesResponses, PostApiMediaSourcesErrors, ThrowOnError>({
|
||||
export const postApiMediaSources = <ThrowOnError extends boolean = false>(options?: Options<PostApiMediaSourcesData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).post<PostApiMediaSourcesResponses, PostApiMediaSourcesErrors, ThrowOnError>({
|
||||
responseType: 'json',
|
||||
url: '/api/media-sources',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1945,7 +1945,7 @@ export type GetChannelsResponses = {
|
||||
export type GetChannelsResponse = GetChannelsResponses[keyof GetChannelsResponses];
|
||||
|
||||
export type CreateChannelV2Data = {
|
||||
body: {
|
||||
body?: {
|
||||
type: 'new';
|
||||
channel: {
|
||||
disableFillerOverlay: boolean;
|
||||
@@ -3495,7 +3495,7 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
||||
export type GetApiChannelsByIdProgrammingResponse = GetApiChannelsByIdProgrammingResponses[keyof GetApiChannelsByIdProgrammingResponses];
|
||||
|
||||
export type PostApiChannelsByIdProgrammingData = {
|
||||
body: {
|
||||
body?: {
|
||||
type: 'manual';
|
||||
programs: Array<{
|
||||
type: 'content';
|
||||
@@ -7098,6 +7098,24 @@ export type GetApiChannelsByIdTranscodeConfigResponses = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
@@ -14747,7 +14765,7 @@ export type GetApiMediaSourcesResponses = {
|
||||
export type GetApiMediaSourcesResponse = GetApiMediaSourcesResponses[keyof GetApiMediaSourcesResponses];
|
||||
|
||||
export type PostApiMediaSourcesData = {
|
||||
body: {
|
||||
body?: {
|
||||
name: string;
|
||||
uri: string;
|
||||
accessToken: string;
|
||||
@@ -15651,7 +15669,7 @@ export type DeleteApiMediaSourcesByIdResponses = {
|
||||
};
|
||||
|
||||
export type PutApiMediaSourcesByIdData = {
|
||||
body: {
|
||||
body?: {
|
||||
id: string;
|
||||
name: string;
|
||||
uri: string;
|
||||
@@ -15883,6 +15901,24 @@ export type GetApiTranscodeConfigsResponses = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
@@ -15920,6 +15956,24 @@ export type PostApiTranscodeConfigsData = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent?: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i?: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra?: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp?: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
@@ -15962,6 +16016,24 @@ export type PostApiTranscodeConfigsResponses = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
@@ -16042,6 +16114,24 @@ export type GetApiTranscodeConfigsByIdResponses = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
@@ -16080,6 +16170,24 @@ export type PutApiTranscodeConfigsByIdData = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent?: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i?: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra?: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp?: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
@@ -16124,6 +16232,24 @@ export type PutApiTranscodeConfigsByIdResponses = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
@@ -16185,6 +16311,24 @@ export type PostApiTranscodeConfigsByIdCopyResponses = {
|
||||
audioBufferSize: number;
|
||||
audioSampleRate: number;
|
||||
audioVolumePercent: number;
|
||||
audioLoudnormConfig?: {
|
||||
/**
|
||||
* integrated loudness target
|
||||
*/
|
||||
i: number;
|
||||
/**
|
||||
* loudness range target
|
||||
*/
|
||||
lra: number;
|
||||
/**
|
||||
* maximum true peak
|
||||
*/
|
||||
tp: number;
|
||||
/**
|
||||
* offset gain to add before peak limiter
|
||||
*/
|
||||
offsetGain?: number;
|
||||
};
|
||||
normalizeFrameRate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
disableChannelOverlay: boolean;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Channel } from '@tunarr/types';
|
||||
import { range } from 'lodash-es';
|
||||
import { type MarkOptional } from 'ts-essentials';
|
||||
import type { DropdownOption } from './DropdownOption';
|
||||
|
||||
export const OneDayMillis = 1000 * 60 * 60 * 24;
|
||||
export const OneWeekMillis = OneDayMillis * 7;
|
||||
@@ -41,19 +42,19 @@ export const DefaultChannel: MarkOptional<
|
||||
} as const;
|
||||
|
||||
export const TranscodeResolutionOptions = [
|
||||
{ value: '420x420', label: '420x420 (1:1)' },
|
||||
{ value: '480x270', label: '480x270 (HD1080/16 16:9)' },
|
||||
{ value: '576x320', label: '576x320 (18:10)' },
|
||||
{ value: '640x360', label: '640x360 (nHD 16:9)' },
|
||||
{ value: '720x480', label: '720x480 (WVGA 3:2)' },
|
||||
{ value: '800x480', label: '800x480 (WVGA 15:9)' },
|
||||
{ value: '854x480', label: '854x480 (FWVGA 16:9)' },
|
||||
{ value: '800x600', label: '800x600 (SVGA 4:3)' },
|
||||
{ value: '1024x768', label: '1024x768 (WXGA 4:3)' },
|
||||
{ value: '1280x720', label: '1280x720 (HD 16:9)' },
|
||||
{ value: '1920x1080', label: '1920x1080 (FHD 16:9)' },
|
||||
{ value: '3840x2160', label: '3840x2160 (4K 16:9)' },
|
||||
] as const;
|
||||
{ value: '420x420', description: '420x420 (1:1)' },
|
||||
{ value: '480x270', description: '480x270 (HD1080/16 16:9)' },
|
||||
{ value: '576x320', description: '576x320 (18:10)' },
|
||||
{ value: '640x360', description: '640x360 (nHD 16:9)' },
|
||||
{ value: '720x480', description: '720x480 (WVGA 3:2)' },
|
||||
{ value: '800x480', description: '800x480 (WVGA 15:9)' },
|
||||
{ value: '854x480', description: '854x480 (FWVGA 16:9)' },
|
||||
{ value: '800x600', description: '800x600 (SVGA 4:3)' },
|
||||
{ value: '1024x768', description: '1024x768 (WXGA 4:3)' },
|
||||
{ value: '1280x720', description: '1280x720 (HD 16:9)' },
|
||||
{ value: '1920x1080', description: '1920x1080 (FHD 16:9)' },
|
||||
{ value: '3840x2160', description: '3840x2160 (4K 16:9)' },
|
||||
] satisfies DropdownOption<string>[];
|
||||
|
||||
export const Plex = 'plex';
|
||||
export const Jellyfin = 'jellyfin';
|
||||
|
||||
@@ -165,7 +165,8 @@ export const handleNumericFormValue = (
|
||||
|
||||
// Special-case for typing a trailing decimal on a float
|
||||
if (float && value.endsWith('.')) {
|
||||
return parseFloat(value + '0'); // This still doesn't work
|
||||
console.log(value, parseFloat(value + '0'));
|
||||
return parseFloat(value + '0'); // TODO: This still doesn't work
|
||||
}
|
||||
|
||||
return float ? parseFloat(value) : parseInt(value);
|
||||
|
||||
22
web/src/hooks/form.ts
Normal file
22
web/src/hooks/form.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createFormHook, createFormHookContexts } from '@tanstack/react-form';
|
||||
import { BasicCheckboxInput } from '../components/form/BasicCheckboxInput.tsx';
|
||||
import {
|
||||
BasicSelectInput,
|
||||
SelectInput,
|
||||
} from '../components/form/BasicSelectInput.tsx';
|
||||
import { BasicTextInput } from '../components/form/BasicTextInput.tsx';
|
||||
|
||||
export const { formContext, fieldContext, useFormContext, useFieldContext } =
|
||||
createFormHookContexts();
|
||||
|
||||
export const { useAppForm, withForm, useTypedAppFormContext } = createFormHook({
|
||||
fieldComponents: {
|
||||
BasicSelectInput,
|
||||
BasicTextInput,
|
||||
SelectInput,
|
||||
BasicCheckboxInput,
|
||||
},
|
||||
formComponents: {},
|
||||
fieldContext,
|
||||
formContext,
|
||||
});
|
||||
@@ -3,35 +3,10 @@ import { TranscodeConfigSettingsForm } from '@/components/settings/ffmpeg/Transc
|
||||
import { useTranscodeConfig } from '@/hooks/settingsHooks';
|
||||
import { Route } from '@/routes/settings/ffmpeg_/$configId';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getApiTranscodeConfigsQueryKey,
|
||||
putApiTranscodeConfigsByIdMutation,
|
||||
} from '../../generated/@tanstack/react-query.gen.ts';
|
||||
|
||||
export const EditTranscodeConfigSettingsPage = () => {
|
||||
const { configId } = Route.useParams();
|
||||
|
||||
const transcodeConfig = useTranscodeConfig(configId);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
...putApiTranscodeConfigsByIdMutation(),
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: getApiTranscodeConfigsQueryKey(),
|
||||
exact: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TranscodeConfigSettingsForm
|
||||
initialConfig={transcodeConfig.data}
|
||||
onSave={(conf) =>
|
||||
updateConfigMutation.mutateAsync({ path: { id: configId }, body: conf })
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <TranscodeConfigSettingsForm initialConfig={transcodeConfig.data} />;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { TranscodeConfigSettingsForm } from '@/components/settings/ffmpeg/TranscodeConfigSettingsForm';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { TranscodeConfig } from '@tunarr/types';
|
||||
import {
|
||||
getApiTranscodeConfigsQueryKey,
|
||||
postApiTranscodeConfigsMutation,
|
||||
} from '../../generated/@tanstack/react-query.gen.ts';
|
||||
|
||||
const defaultNewTranscodeConfig: TranscodeConfig = {
|
||||
id: '',
|
||||
@@ -41,22 +36,9 @@ const defaultNewTranscodeConfig: TranscodeConfig = {
|
||||
};
|
||||
|
||||
export const NewTranscodeConfigSettingsPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
...postApiTranscodeConfigsMutation(),
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: getApiTranscodeConfigsQueryKey(),
|
||||
exact: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TranscodeConfigSettingsForm
|
||||
initialConfig={defaultNewTranscodeConfig}
|
||||
onSave={(conf) => updateConfigMutation.mutateAsync({ body: conf })}
|
||||
isNew
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -46,3 +46,8 @@ export const setUiLocale = (locale: SupportedLocales) =>
|
||||
dayjs.locale(locale); // Changes the default dayjs locale globally
|
||||
settings.ui.i18n.locale = locale;
|
||||
});
|
||||
|
||||
export const setShowAdvancedSettings = (value: boolean) =>
|
||||
useStore.setState(({ settings }) => {
|
||||
settings.ui.showAdvancedSettings = value;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PaginationState } from '@tanstack/react-table';
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// Only these 2 are supported currently
|
||||
@@ -19,6 +19,7 @@ export interface SettingsStateInternal {
|
||||
locale: SupportedLocales;
|
||||
};
|
||||
tableSettings: Record<string, TableSettings>;
|
||||
showAdvancedSettings: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ export const createSettingsSlice: StateCreator<SettingsState> = () => ({
|
||||
locale: 'en',
|
||||
},
|
||||
tableSettings: {},
|
||||
showAdvancedSettings: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user