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:
Christian Benincasa
2026-02-13 15:09:01 -05:00
parent d54a728d2f
commit f25f3f599f
47 changed files with 6175 additions and 1257 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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(),

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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),

View 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}`;
}
}

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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> = {}) {

View File

@@ -196,6 +196,9 @@ export class DirectMigrationProvider implements MigrationProvider {
'./sql/0040_daffy_bishop.sql',
true,
),
migration1770236998: makeKyselyMigrationFromSqlFile(
'./sql/0041_easy_firebird.sql',
),
},
wrapWithTransaction,
),

View File

@@ -0,0 +1 @@
ALTER TABLE `transcode_config` ADD `audio_loudnorm_config` text;

File diff suppressed because it is too large Load Diff

View File

@@ -288,6 +288,13 @@
"when": 1769361500423,
"tag": "0040_daffy_bishop",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1770236977185,
"tag": "0041_easy_firebird",
"breakpoints": true
}
]
}

View File

@@ -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",

View File

@@ -1,4 +1,4 @@
import type { z } from 'zod/v4';
import type { z } from 'zod';
import type {
SupportedTranscodeVideoOutputFormats,
TranscodeConfigSchema,

View File

@@ -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>;

View File

@@ -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'),
});

View File

@@ -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(),

View File

@@ -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",

View 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>
);
}

View 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>
);
}

View 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}
</>
}
/>
);
}

View File

@@ -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;
};

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -153,7 +153,6 @@ export const TranscodeConfigsTable = () => {
visibleInShowHideMenu: false,
},
},
positionActionsColumn: 'last',
renderTopToolbarCustomActions() {
return (
<Stack direction="row" alignItems="center" gap={2} useFlexGap>

View File

@@ -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,
});
}
},
});
};

View File

@@ -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}
/>
);

View File

@@ -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 }}
/> */}
</>
),
};

View File

@@ -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({

View File

@@ -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
}
});
};

View File

@@ -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;

View File

@@ -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';

View File

@@ -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
View 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,
});

View File

@@ -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} />;
};

View File

@@ -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
/>
);

View File

@@ -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;
});

View File

@@ -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,
},
},
});