diff --git a/server/src/api/debug/debugFfmpegApi.ts b/server/src/api/debug/debugFfmpegApi.ts index f8790278..43a03788 100644 --- a/server/src/api/debug/debugFfmpegApi.ts +++ b/server/src/api/debug/debugFfmpegApi.ts @@ -38,9 +38,14 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async ( const channel = await req.serverCtx.channelDB.getChannel( req.query.channel, ); + if (!channel) { return res.status(404).send(); } + + const transcodeConfig = + await req.serverCtx.transcodeConfigDB.getChannelConfig(channel.uuid); + const details = new LocalFileStreamDetails(req.query.path); const streamDetails = await details.getStream(); @@ -50,6 +55,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async ( const ffmpeg = new FfmpegStreamFactory( req.serverCtx.settings.ffmpegSettings(), + transcodeConfig, channel, ); diff --git a/server/src/api/debug/debugStreamApi.ts b/server/src/api/debug/debugStreamApi.ts index f6d34f99..c44f9aa8 100644 --- a/server/src/api/debug/debugStreamApi.ts +++ b/server/src/api/debug/debugStreamApi.ts @@ -2,6 +2,10 @@ import { getDatabase } from '@/db/DBAccess.ts'; import { createOfflineStreamLineupItem } from '@/db/derived_types/StreamLineup.ts'; import { AllChannelTableKeys, Channel } from '@/db/schema/Channel.ts'; import { ProgramDao, ProgramType } from '@/db/schema/Program.ts'; +import { + AllTranscodeConfigColumns, + TranscodeConfig, +} from '@/db/schema/TranscodeConfig.ts'; import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.ts'; import { serverContext } from '@/serverContext.ts'; import { OfflineProgramStream } from '@/stream/OfflinePlayer.ts'; @@ -12,7 +16,7 @@ import { PlexProgramStream } from '@/stream/plex/PlexProgramStream.ts'; import { TruthyQueryParam } from '@/types/schemas.ts'; import { RouterPluginAsyncCallback } from '@/types/serverType.ts'; import { jsonObjectFrom } from 'kysely/helpers/sqlite'; -import { first, isNumber, isUndefined, nth, random } from 'lodash-es'; +import { isNumber, isUndefined, nth, random } from 'lodash-es'; import { PassThrough } from 'stream'; import { z } from 'zod'; @@ -34,6 +38,19 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( const channel = await getDatabase() .selectFrom('channel') .selectAll() + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('transcodeConfig') + .whereRef( + 'transcodeConfig.uuid', + '=', + 'channel.transcodeConfigId', + ) + .select(AllTranscodeConfigColumns), + ).as('transcodeConfig'), + ) + .$narrowType<{ transcodeConfig: TranscodeConfig }>() .executeTakeFirstOrThrow(); const stream = new OfflineProgramStream( @@ -47,7 +64,8 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( false, false, true, - req.query.useNewPipeline, + req.query.useNewPipeline ?? false, + channel.transcodeConfig, ), MpegTsOutputFormat, ); @@ -72,7 +90,21 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( const channel = await getDatabase() .selectFrom('channel') .selectAll() + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('transcodeConfig') + .whereRef( + 'transcodeConfig.uuid', + '=', + 'channel.transcodeConfigId', + ) + .select(AllTranscodeConfigColumns), + ).as('transcodeConfig'), + ) + .$narrowType<{ transcodeConfig: TranscodeConfig }>() .executeTakeFirstOrThrow(); + const stream = new OfflineProgramStream( true, PlayerContext.error( @@ -80,7 +112,8 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( '', channel, true, - req.query.useNewPipeline, + req.query.useNewPipeline ?? false, + channel.transcodeConfig, ), MpegTsOutputFormat, ); @@ -109,6 +142,18 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( eb .selectFrom('channel') .whereRef('channel.uuid', '=', 'channelPrograms.channelUuid') + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('transcodeConfig') + .whereRef( + 'transcodeConfig.uuid', + '=', + 'channel.transcodeConfigId', + ) + .select(AllTranscodeConfigColumns), + ).as('transcodeConfig'), + ) .select(AllChannelTableKeys), ).as('channel'), ) @@ -120,7 +165,11 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( return res.status(404); } - const out = await initStream(program, firstChannel); + const out = await initStream( + program, + firstChannel, + firstChannel.transcodeConfig!, + ); return res.header('Content-Type', 'video/mp2t').send(out); }); @@ -161,6 +210,18 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( eb .selectFrom('channel') .whereRef('channel.uuid', '=', 'channelPrograms.channelUuid') + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('transcodeConfig') + .whereRef( + 'transcodeConfig.uuid', + '=', + 'channel.transcodeConfigId', + ) + .select(AllTranscodeConfigColumns), + ).as('transcodeConfig'), + ) .select(AllChannelTableKeys), ).as('channel'), ) @@ -169,17 +230,29 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( let firstChannel = nth(channels, 0)?.channel; if (!firstChannel) { - firstChannel = await req.serverCtx.channelDB - .getAllChannels() - .then((channels) => first(channels) ?? null); - if (!firstChannel) { - return res.status(404); - } + firstChannel = await getDatabase() + .selectFrom('channel') + .selectAll() + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('transcodeConfig') + .whereRef( + 'transcodeConfig.uuid', + '=', + 'channel.transcodeConfigId', + ) + .select(AllTranscodeConfigColumns), + ).as('transcodeConfig'), + ) + .$narrowType<{ transcodeConfig: TranscodeConfig }>() + .executeTakeFirstOrThrow(); } const outStream = await initStream( program, firstChannel, + firstChannel.transcodeConfig!, startTime * 1000, req.query.useNewPipeline, ); @@ -190,6 +263,7 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( async function initStream( program: ProgramDao, channel: Channel, + transcodeConfig: TranscodeConfig, startTime: number = 0, useNewPipeline: boolean = false, ) { @@ -204,6 +278,7 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async ( false, true, useNewPipeline, + transcodeConfig, ); let stream: ProgramStream; diff --git a/server/src/api/ffmpegSettingsApi.ts b/server/src/api/ffmpegSettingsApi.ts index 89b01dc3..4d32c6fd 100644 --- a/server/src/api/ffmpegSettingsApi.ts +++ b/server/src/api/ffmpegSettingsApi.ts @@ -1,4 +1,4 @@ -import { TrannscodeConfig as TrannscodeConfigDao } from '@/db/schema/TranscodeConfig.ts'; +import { TranscodeConfig as TrannscodeConfigDao } from '@/db/schema/TranscodeConfig.ts'; import { serverOptions } from '@/globals.js'; import { RouterPluginCallback } from '@/types/serverType.js'; import { firstDefined } from '@/util/index.js'; diff --git a/server/src/api/index.ts b/server/src/api/index.ts index b175305d..36d7fc58 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -1,6 +1,6 @@ import { MediaSourceType } from '@/db/schema/MediaSource.ts'; import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js'; -import { FFMPEGInfo } from '@/ffmpeg/ffmpegInfo.js'; +import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js'; import { serverOptions } from '@/globals.js'; import { GlobalScheduler } from '@/services/Scheduler.ts'; import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js'; @@ -81,7 +81,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { async (req, res) => { try { const ffmpegSettings = req.serverCtx.settings.ffmpegSettings(); - const v = await new FFMPEGInfo(ffmpegSettings).getVersion(); + const v = await new FfmpegInfo(ffmpegSettings).getVersion(); let tunarrVersion: string = getTunarrVersion(); if (!isProduction) { tunarrVersion += `-dev`; @@ -101,7 +101,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { ); fastify.get('/ffmpeg-info', async (req, res) => { - const info = new FFMPEGInfo(req.serverCtx.settings.ffmpegSettings()); + const info = new FfmpegInfo(req.serverCtx.settings.ffmpegSettings()); const [audioEncoders, videoEncoders] = await Promise.all([ run(async () => { const res = await info.getAvailableAudioEncoders(); diff --git a/server/src/db/ChannelDB.ts b/server/src/db/ChannelDB.ts index dae4e304..aa0b98a0 100644 --- a/server/src/db/ChannelDB.ts +++ b/server/src/db/ChannelDB.ts @@ -1,3 +1,4 @@ +import { ChannelQueryBuilder } from '@/db/ChannelQueryBuilder.ts'; import { globalOptions } from '@/globals.ts'; import { serverContext } from '@/serverContext.ts'; import { ChannelNotFoundError } from '@/types/errors.ts'; @@ -97,7 +98,10 @@ import { } from './schema/Channel.ts'; import { programExternalIdString } from './schema/Program.ts'; import { ChannelTranscodingSettings } from './schema/base.ts'; -import { ChannelWithPrograms as RawChannelWithPrograms } from './schema/derivedTypes.js'; +import { + ChannelWithRelations, + ChannelWithPrograms as RawChannelWithPrograms, +} from './schema/derivedTypes.js'; // We use this to chunk super huge channel / program relation updates because // of the way that mikro-orm generates these (e.g. "delete from XYZ where () or () ..."). @@ -158,31 +162,6 @@ function updateRequestToChannel(updateReq: SaveChannelRequest): ChannelUpdate { guideFlexTitle: updateReq.guideFlexTitle, transcodeConfigId: updateReq.transcodeConfigId, } satisfies ChannelUpdate; - // return omitBy( - // { - // number: updateReq.number, - // watermark: sanitizeChannelWatermark(updateReq.watermark), - // icon: updateReq.icon, - // guideMinimumDuration: updateReq.guideMinimumDuration, - // groupTitle: updateReq.groupTitle, - // disableFillerOverlay: updateReq.disableFillerOverlay, - // startTime: updateReq.startTime, - // offline: updateReq.offline, - // name: updateReq.name, - // transcoding: isEmpty(transcoding) - // ? undefined - // : { - // targetResolution: transcoding?.targetResolution, - // videoBitrate: transcoding?.videoBitrate, - // videoBufferSize: transcoding?.videoBufferSize, - // }, - // duration: updateReq.duration, - // stealth: updateReq.stealth, - // fillerRepeatCooldown: updateReq.fillerRepeatCooldown, - // guideFlexTitle: updateReq.guideFlexTitle, - // }, - // isNil, - // ); } function createRequestToChannel(saveReq: SaveChannelRequest): NewChannel { @@ -260,7 +239,15 @@ export class ChannelDB { return !isNil(channel); } - getChannel(id: string | number, includeFiller: boolean = false) { + getChannel(id: string | number): Promise>; + getChannel( + id: string | number, + includeFiller: true, + ): Promise>>; + getChannel( + id: string | number, + includeFiller: boolean = false, + ): Promise> { return getDatabase() .selectFrom('channel') .$if(isString(id), (eb) => eb.where('channel.uuid', '=', id as string)) @@ -284,6 +271,10 @@ export class ChannelDB { .executeTakeFirst(); } + getChannelBuilder(id: string | number) { + return ChannelQueryBuilder.createForIdOrNumber(getDatabase(), id); + } + getChannelAndPrograms( uuid: string, ): Promise { diff --git a/server/src/db/ChannelQueryBuilder.ts b/server/src/db/ChannelQueryBuilder.ts new file mode 100644 index 00000000..e012df5c --- /dev/null +++ b/server/src/db/ChannelQueryBuilder.ts @@ -0,0 +1,89 @@ +import { AllTranscodeConfigColumns } from '@/db/schema/TranscodeConfig.ts'; +import { DB } from '@/db/schema/db.ts'; +import { + ChannelWithRelations, + ChannelWithTranscodeConfig, +} from '@/db/schema/derivedTypes.js'; +import { Maybe } from '@/types/util.ts'; +import { Kysely, NotNull, SelectQueryBuilder } from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite'; +import { isString } from 'lodash-es'; + +export class ChannelQueryBuilder { + private constructor( + private builder: SelectQueryBuilder, + ) {} + + static create(db: Kysely) { + return new ChannelQueryBuilder(db.selectFrom('channel').selectAll()); + } + + static createForIdOrNumber(db: Kysely, idOrNumber: string | number) { + return this.create(db).withIdOrNumber(idOrNumber); + } + + withId(uuid: string) { + return new ChannelQueryBuilder( + this.builder.where('channel.uuid', '=', uuid), + ); + } + + withNumber(number: number) { + return new ChannelQueryBuilder( + this.builder.where('channel.number', '=', number), + ); + } + + withIdOrNumber(idOrNumber: string | number) { + if (isString(idOrNumber)) { + return this.withId(idOrNumber); + } else { + return this.withNumber(idOrNumber); + } + } + + withFillerShows() { + return new ChannelQueryBuilder( + this.builder.select((qb) => + jsonArrayFrom( + qb + .selectFrom('channelFillerShow') + .whereRef('channel.uuid', '=', 'channelFillerShow.channelUuid') + .select([ + 'channelFillerShow.channelUuid', + 'channelFillerShow.fillerShowUuid', + 'channelFillerShow.cooldown', + 'channelFillerShow.weight', + ]), + ).as('fillerShows'), + ), + ); + } + + withTranscodeConfig(): ChannelQueryBuilder { + return new ChannelQueryBuilder( + this.builder + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('transcodeConfig') + .whereRef( + 'transcodeConfig.uuid', + '=', + 'channel.transcodeConfigId', + ) + .select(AllTranscodeConfigColumns), + ).as('transcodeConfig'), + ) + .$narrowType<{ transcodeConfig: NotNull }>(), + ); + } + + execute(): Promise { + return this.builder.execute(); + } + + executeTakeFirst(): Promise> { + return this.builder.executeTakeFirst(); + } +} diff --git a/server/src/db/TranscodeConfigDB.ts b/server/src/db/TranscodeConfigDB.ts index 4c83d280..94b03163 100644 --- a/server/src/db/TranscodeConfigDB.ts +++ b/server/src/db/TranscodeConfigDB.ts @@ -32,6 +32,31 @@ export class TranscodeConfigDB { .executeTakeFirst(); } + async getChannelConfig(channelId: string) { + const channelConfig = await getDatabase() + .selectFrom('channel') + .where('channel.uuid', '=', channelId) + .innerJoin( + 'transcodeConfig', + 'channel.transcodeConfigId', + 'transcodeConfig.uuid', + ) + .selectAll('transcodeConfig') + .limit(1) + .executeTakeFirst(); + + if (channelConfig) { + return channelConfig; + } + + return getDatabase() + .selectFrom('transcodeConfig') + .where('isDefault', '=', 1) + .selectAll() + .limit(1) + .executeTakeFirstOrThrow(); + } + insertConfig(config: Omit) { const id = v4(); const newConfig: NewTranscodeConfig = { diff --git a/server/src/db/schema/TranscodeConfig.ts b/server/src/db/schema/TranscodeConfig.ts index c0483e53..4cb5bae3 100644 --- a/server/src/db/schema/TranscodeConfig.ts +++ b/server/src/db/schema/TranscodeConfig.ts @@ -61,6 +61,40 @@ export type ErrorScreenType = TupleToUnion; export const ErrorScreenAudioTypes = ['silent', 'sine', 'whitenoise'] as const; export type ErrorScreenAudioType = TupleToUnion; +export const TranscodeConfigColumns: (keyof TrannscodeConfigTable)[] = [ + 'audioBitRate', + 'audioBufferSize', + 'audioChannels', + 'audioFormat', + 'audioSampleRate', + 'audioVolumePercent', + 'deinterlaceVideo', + 'disableChannelOverlay', + 'errorScreen', + 'errorScreenAudio', + 'hardwareAccelerationMode', + 'isDefault', + 'name', + 'normalizeFrameRate', + 'resolution', + 'threadCount', + 'uuid', + 'vaapiDevice', + 'vaapiDriver', + 'videoBitDepth', + 'videoBitRate', + 'videoBufferSize', + 'videoFormat', + 'videoPreset', + 'videoProfile', +] as const; + +type TranscodeConfigFields = + readonly `${Alias}.${keyof TrannscodeConfigTable}`[]; + +export const AllTranscodeConfigColumns: TranscodeConfigFields = + TranscodeConfigColumns.map((key) => `transcodeConfig.${key}` as const); + export interface TrannscodeConfigTable extends WithUuid { name: string; threadCount: number; @@ -93,6 +127,6 @@ export interface TrannscodeConfigTable extends WithUuid { isDefault: Generated; // boolean, } -export type TrannscodeConfig = Selectable; +export type TranscodeConfig = Selectable; export type NewTranscodeConfig = Insertable; export type TranscodeConfigUpdate = Updateable; diff --git a/server/src/db/schema/derivedTypes.d.ts b/server/src/db/schema/derivedTypes.d.ts index 4476f38e..6c861e13 100644 --- a/server/src/db/schema/derivedTypes.d.ts +++ b/server/src/db/schema/derivedTypes.d.ts @@ -1,3 +1,4 @@ +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; import { MarkNonNullable } from '@/types/util.ts'; import type { DeepNullable, MarkRequired } from 'ts-essentials'; import { Channel, ChannelFillerShow } from './Channel.ts'; @@ -16,25 +17,18 @@ export type ProgramDaoWithRelations = ProgramDao & { externalIds?: MinimalProgramExternalId[]; }; -// export type Channel = Omit< -// Channel, -// 'icon' | 'offline' | 'watermark' | 'transcoding' | 'streamMode' -// > & { -// icon?: ChannelIcon; -// offline?: ChannelOfflineSettings; -// watermark?: ChannelWatermark; -// transcoding?: ChannelTranscodingSettings; -// streamMode: ChannelStreamMode; -// } & { -// programs?: Program[]; -// }; - export type ChannelWithRelations = Channel & { programs?: ProgramDaoWithRelations[]; fillerContent?: ProgramDaoWithRelations[]; fillerShows?: ChannelFillerShow[]; + transcodeConfig?: TranscodeConfig; }; +export type ChannelWithTranscodeConfig = MarkRequired< + ChannelWithRelations, + 'transcodeConfig' +>; + export type ChannelWithRequiredJoins = MarkRequired; diff --git a/server/src/ffmpeg/FFmpegFactory.ts b/server/src/ffmpeg/FFmpegFactory.ts index 9f7f1e65..22858055 100644 --- a/server/src/ffmpeg/FFmpegFactory.ts +++ b/server/src/ffmpeg/FFmpegFactory.ts @@ -1,4 +1,5 @@ import { Channel } from '@/db/schema/Channel.ts'; +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; import { FfmpegSettings } from '@tunarr/types'; import { FfmpegStreamFactory } from './FfmpegStreamFactory.ts'; import { FFMPEG } from './ffmpeg.ts'; @@ -7,12 +8,13 @@ import { IFFMPEG } from './ffmpegBase.ts'; export class FFmpegFactory { static getFFmpegPipelineBuilder( settings: FfmpegSettings, + transcodeConfig: TranscodeConfig, channel: Channel, ): IFFMPEG { if (settings.useNewFfmpegPipeline) { - return new FfmpegStreamFactory(settings, channel); + return new FfmpegStreamFactory(settings, transcodeConfig, channel); } else { - return new FFMPEG(settings, channel); + return new FFMPEG(settings, transcodeConfig, channel); } } } diff --git a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts index a5545d77..8758c962 100644 --- a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts +++ b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts @@ -1,8 +1,8 @@ +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; import { StreamDetails, VideoStreamDetails } from '@/stream/types.ts'; import { gcd } from '@/util/index.ts'; -import { FfmpegSettings, Resolution } from '@tunarr/types'; -import { isNil, round } from 'lodash-es'; -import { DeepReadonly } from 'ts-essentials'; +import { numberToBoolean } from '@/util/sqliteUtil.ts'; +import { Resolution } from '@tunarr/types'; import { OutputFormat } from './builder/constants.ts'; import { PixelFormat, @@ -11,29 +11,32 @@ import { import { FrameSize, HardwareAccelerationMode } from './builder/types.ts'; export class FfmpegPlaybackParamsCalculator { - constructor(private ffmpegOptions: DeepReadonly) {} + constructor(private transcodeConfig: TranscodeConfig) {} calculateForStream(streamDetails: StreamDetails): FfmpegPlaybackParams { // TODO: Check channel mode; const params: FfmpegPlaybackParams = { - audioFormat: this.ffmpegOptions.audioEncoder, - audioBitrate: this.ffmpegOptions.audioBitrate, - audioBufferSize: this.ffmpegOptions.audioBufferSize, - audioChannels: this.ffmpegOptions.audioChannels, - audioSampleRate: this.ffmpegOptions.audioSampleRate, - hwAccel: this.ffmpegOptions.hardwareAccelerationMode, - videoFormat: this.ffmpegOptions.videoFormat, - videoBitrate: this.ffmpegOptions.videoBitrate, - videoBufferSize: this.ffmpegOptions.videoBufferSize, + audioFormat: this.transcodeConfig.audioFormat, + audioBitrate: this.transcodeConfig.audioBitRate, + audioBufferSize: this.transcodeConfig.audioBufferSize, + audioChannels: this.transcodeConfig.audioChannels, + audioSampleRate: this.transcodeConfig.audioSampleRate, + hwAccel: this.transcodeConfig.hardwareAccelerationMode, + videoFormat: this.transcodeConfig.videoFormat, + videoBitrate: this.transcodeConfig.videoBitRate, + videoBufferSize: this.transcodeConfig.videoBufferSize, }; if (streamDetails.videoDetails) { const [videoStream] = streamDetails.videoDetails; if ( - needsToScale(this.ffmpegOptions, videoStream) && + needsToScale(this.transcodeConfig, videoStream) && videoStream.sampleAspectRatio !== '0:0' ) { - const scaledSize = calculateScaledSize(this.ffmpegOptions, videoStream); + const scaledSize = calculateScaledSize( + this.transcodeConfig, + videoStream, + ); if ( scaledSize.widthPx !== videoStream.width || scaledSize.heightPx !== videoStream.height @@ -49,20 +52,19 @@ export class FfmpegPlaybackParamsCalculator { height: videoStream.height, }); if ( - sizeAfterScaling.width !== - this.ffmpegOptions.targetResolution.widthPx || - sizeAfterScaling.height !== this.ffmpegOptions.targetResolution.heightPx + sizeAfterScaling.width !== this.transcodeConfig.resolution.widthPx || + sizeAfterScaling.height !== this.transcodeConfig.resolution.heightPx ) { params.needsPad = true; } // We only have an option for maxFPS right now... - if ( - isNil(videoStream.framerate) || - round(videoStream.framerate, 3) > this.ffmpegOptions.maxFPS - ) { - params.frameRate = this.ffmpegOptions.maxFPS; - } + // if ( + // isNil(videoStream.framerate) || + // round(videoStream.framerate, 3) > this.ffmpegOptions.maxFPS + // ) { + // params.frameRate = this.ffmpegOptions.maxFPS; + // } params.videoTrackTimeScale = 90000; @@ -74,7 +76,7 @@ export class FfmpegPlaybackParamsCalculator { params.pixelFormat = new PixelFormatYuv420P(); params.deinterlace = - this.ffmpegOptions.deinterlaceFilter !== 'none' && + numberToBoolean(this.transcodeConfig.deinterlaceVideo) && videoStream.scanType === 'interlaced'; } @@ -86,15 +88,15 @@ export class FfmpegPlaybackParamsCalculator { hlsRealtime: boolean, ): FfmpegPlaybackParams { return { - audioFormat: this.ffmpegOptions.audioEncoder, - audioBitrate: this.ffmpegOptions.audioBitrate, - audioBufferSize: this.ffmpegOptions.audioBufferSize, - audioChannels: this.ffmpegOptions.audioChannels, - audioSampleRate: this.ffmpegOptions.audioSampleRate, - hwAccel: this.ffmpegOptions.hardwareAccelerationMode, - videoFormat: this.ffmpegOptions.videoFormat, - videoBitrate: this.ffmpegOptions.videoBitrate, - videoBufferSize: this.ffmpegOptions.videoBufferSize, + audioFormat: this.transcodeConfig.audioFormat, + audioBitrate: this.transcodeConfig.audioBitRate, + audioBufferSize: this.transcodeConfig.audioBufferSize, + audioChannels: this.transcodeConfig.audioChannels, + audioSampleRate: this.transcodeConfig.audioSampleRate, + hwAccel: this.transcodeConfig.hardwareAccelerationMode, + videoFormat: this.transcodeConfig.videoFormat, + videoBitrate: this.transcodeConfig.videoBitRate, + videoBufferSize: this.transcodeConfig.videoBufferSize, videoTrackTimeScale: 90_000, frameRate: 24, realtime: outputFormat.type === 'hls' ? hlsRealtime : true, @@ -103,15 +105,15 @@ export class FfmpegPlaybackParamsCalculator { calculateForHlsConcat() { return { - audioFormat: this.ffmpegOptions.audioEncoder, - audioBitrate: this.ffmpegOptions.audioBitrate, - audioBufferSize: this.ffmpegOptions.audioBufferSize, - audioChannels: this.ffmpegOptions.audioChannels, - audioSampleRate: this.ffmpegOptions.audioSampleRate, - hwAccel: this.ffmpegOptions.hardwareAccelerationMode, - videoFormat: this.ffmpegOptions.videoFormat, - videoBitrate: this.ffmpegOptions.videoBitrate, - videoBufferSize: this.ffmpegOptions.videoBufferSize, + audioFormat: this.transcodeConfig.audioFormat, + audioBitrate: this.transcodeConfig.audioBitRate, + audioBufferSize: this.transcodeConfig.audioBufferSize, + audioChannels: this.transcodeConfig.audioChannels, + audioSampleRate: this.transcodeConfig.audioSampleRate, + hwAccel: this.transcodeConfig.hardwareAccelerationMode, + videoFormat: this.transcodeConfig.videoFormat, + videoBitrate: this.transcodeConfig.videoBitRate, + videoBufferSize: this.transcodeConfig.videoBufferSize, videoTrackTimeScale: 90_000, frameRate: 24, realtime: false, @@ -144,14 +146,14 @@ export type FfmpegPlaybackParams = { }; function needsToScale( - ffmpegOptions: DeepReadonly, + transcodeConfig: TranscodeConfig, videoStreamDetails: VideoStreamDetails, ) { return ( isAnamorphic(videoStreamDetails) || actualSizeDiffersFromDesired( videoStreamDetails, - ffmpegOptions.targetResolution, + transcodeConfig.resolution, ) || videoStreamDetails.width % 2 == 1 || videoStreamDetails.height % 2 == 1 @@ -192,11 +194,10 @@ function isAnamorphic(videoStreamDetails: VideoStreamDetails) { } function calculateScaledSize( - ffmpegOptions: DeepReadonly, + config: TranscodeConfig, videoStream: VideoStreamDetails, ) { - const { widthPx: targetW, heightPx: targetH } = - ffmpegOptions.targetResolution; + const { widthPx: targetW, heightPx: targetH } = config.resolution; const [width, height] = videoStream.sampleAspectRatio .split(':') .map((i) => parseInt(i)); diff --git a/server/src/ffmpeg/FfmpegProcess.ts b/server/src/ffmpeg/FfmpegProcess.ts index 6975e0e9..78c2ebfc 100644 --- a/server/src/ffmpeg/FfmpegProcess.ts +++ b/server/src/ffmpeg/FfmpegProcess.ts @@ -95,7 +95,6 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE }`; } - // const test = createWriteStream('./test.log', { flags: 'a' }); this.#processHandle = spawn(this.ffmpegPath, this.ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'], env, diff --git a/server/src/ffmpeg/FfmpegStreamFactory.ts b/server/src/ffmpeg/FfmpegStreamFactory.ts index 2193f5f2..91c69a3a 100644 --- a/server/src/ffmpeg/FfmpegStreamFactory.ts +++ b/server/src/ffmpeg/FfmpegStreamFactory.ts @@ -1,4 +1,5 @@ import { Channel } from '@/db/schema/Channel.ts'; +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; import { HttpStreamSource } from '@/stream/types.ts'; import { Maybe, Nullable } from '@/types/util.ts'; import { isDefined, isLinux, isNonEmptyString } from '@/util/index.ts'; @@ -44,18 +45,19 @@ import { FrameState } from './builder/state/FrameState.ts'; import { FrameSize } from './builder/types.ts'; import { ConcatOptions, StreamSessionOptions } from './ffmpeg.ts'; import { HlsWrapperOptions, IFFMPEG } from './ffmpegBase.ts'; -import { FFMPEGInfo } from './ffmpegInfo.ts'; +import { FfmpegInfo } from './ffmpegInfo.ts'; export class FfmpegStreamFactory extends IFFMPEG { - private ffmpegInfo: FFMPEGInfo; + private ffmpegInfo: FfmpegInfo; private pipelineBuilderFactory: PipelineBuilderFactory; constructor( private ffmpegSettings: DeepReadonly, + private transcodeConfig: TranscodeConfig, private channel: Channel, ) { super(); - this.ffmpegInfo = new FFMPEGInfo(ffmpegSettings); + this.ffmpegInfo = new FfmpegInfo(ffmpegSettings); this.pipelineBuilderFactory = new PipelineBuilderFactory(); } @@ -73,8 +75,8 @@ export class FfmpegStreamFactory extends IFFMPEG { const concatInput = new ConcatInputSource( new HttpStreamSource(streamUrl), FrameSize.create({ - height: this.ffmpegSettings.targetResolution.heightPx, - width: this.ffmpegSettings.targetResolution.widthPx, + height: this.transcodeConfig.resolution.heightPx, + width: this.transcodeConfig.resolution.widthPx, }), ); @@ -86,7 +88,7 @@ export class FfmpegStreamFactory extends IFFMPEG { } const pipelineBuilder = await this.pipelineBuilderFactory - .builder(this.ffmpegSettings) + .builder(this.transcodeConfig) .setConcatInputSource(concatInput) .build(); @@ -123,7 +125,7 @@ export class FfmpegStreamFactory extends IFFMPEG { ); const pipelineBuilder = await this.pipelineBuilderFactory - .builder(this.ffmpegSettings) + .builder(this.transcodeConfig) .setConcatInputSource(concatInput) .build(); @@ -152,7 +154,7 @@ export class FfmpegStreamFactory extends IFFMPEG { concatInput: ConcatInputSource, opts: HlsWrapperOptions, ): Promise { - const calculator = new FfmpegPlaybackParamsCalculator(this.ffmpegSettings); + const calculator = new FfmpegPlaybackParamsCalculator(this.transcodeConfig); const playbackParams = calculator.calculateForHlsConcat(); const videoStream = VideoStream.create({ @@ -186,13 +188,15 @@ export class FfmpegStreamFactory extends IFFMPEG { audioDuration: null, audioEncoder: playbackParams.audioFormat, audioSampleRate: playbackParams.audioSampleRate, - audioVolume: this.ffmpegSettings.audioVolumePercent, + audioVolume: this.transcodeConfig.audioVolumePercent, }), ); const pipelineBuilder = await this.pipelineBuilderFactory - .builder(this.ffmpegSettings) - .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) + .builder(this.transcodeConfig) + .setHardwareAccelerationMode( + this.transcodeConfig.hardwareAccelerationMode, + ) .setVideoInputSource(videoInputSource) .setAudioInputSource(audioInputSource) .setConcatInputSource(concatInput) @@ -206,18 +210,14 @@ export class FfmpegStreamFactory extends IFFMPEG { metadataServiceName: this.channel.name, ptsOffset: 0, vaapiDevice: this.getVaapiDevice(), - vaapiDriver: this.ffmpegSettings.vaapiDriver, + vaapiDriver: this.transcodeConfig.vaapiDriver, mapMetadata: true, - threadCount: this.ffmpegSettings.numThreads, + threadCount: this.transcodeConfig.threadCount, }), new FrameState({ realtime: playbackParams.realtime, - scaledSize: FrameSize.fromResolution( - this.ffmpegSettings.targetResolution, - ), - paddedSize: FrameSize.fromResolution( - this.ffmpegSettings.targetResolution, - ), + scaledSize: FrameSize.fromResolution(this.transcodeConfig.resolution), + paddedSize: FrameSize.fromResolution(this.transcodeConfig.resolution), isAnamorphic: false, deinterlaced: false, pixelFormat: new PixelFormatYuv420P(), @@ -267,7 +267,7 @@ export class FfmpegStreamFactory extends IFFMPEG { throw new Error(''); } - const calculator = new FfmpegPlaybackParamsCalculator(this.ffmpegSettings); + const calculator = new FfmpegPlaybackParamsCalculator(this.transcodeConfig); const playbackParams = calculator.calculateForStream(streamDetails); // Get inputs @@ -379,7 +379,7 @@ export class FfmpegStreamFactory extends IFFMPEG { } const builder = await new PipelineBuilderFactory() - .builder() + .builder(this.transcodeConfig) .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) .setVideoInputSource(videoInput) .setAudioInputSource(audioInput) @@ -444,7 +444,7 @@ export class FfmpegStreamFactory extends IFFMPEG { realtime: boolean, ptsOffset?: number, ): Promise> { - const calculator = new FfmpegPlaybackParamsCalculator(this.ffmpegSettings); + const calculator = new FfmpegPlaybackParamsCalculator(this.transcodeConfig); const playbackParams = calculator.calculateForErrorStream( outputFormat, realtime, @@ -517,7 +517,7 @@ export class FfmpegStreamFactory extends IFFMPEG { } const builder = await new PipelineBuilderFactory() - .builder() + .builder(this.transcodeConfig) .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) .setVideoInputSource(errorInput) .setAudioInputSource(audioInput) @@ -590,7 +590,7 @@ export class FfmpegStreamFactory extends IFFMPEG { }), ); - const calculator = new FfmpegPlaybackParamsCalculator(this.ffmpegSettings); + const calculator = new FfmpegPlaybackParamsCalculator(this.transcodeConfig); const playbackParams = calculator.calculateForErrorStream( outputFormat, true, @@ -621,7 +621,7 @@ export class FfmpegStreamFactory extends IFFMPEG { } const builder = await new PipelineBuilderFactory() - .builder() + .builder(this.transcodeConfig) .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) .setVideoInputSource(offlineInput) .setAudioInputSource(audioInput) diff --git a/server/src/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.ts b/server/src/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.ts index 3330e7bd..eee7502b 100644 --- a/server/src/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.ts +++ b/server/src/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.ts @@ -5,6 +5,9 @@ import { RateControlMode } from '@/ffmpeg/builder/types.ts'; import { Maybe, Nilable } from '@/types/util.ts'; import { NvidiaHardwareCapabilities } from './NvidiaHardwareCapabilities.ts'; +export interface FfmpegHardwareCapabilitiesFactory { + getCapabilities(): Promise; +} export abstract class BaseFfmpegHardwareCapabilities { readonly type: string; constructor() {} diff --git a/server/src/ffmpeg/builder/capabilities/HardwareCapabilitiesFactory.ts b/server/src/ffmpeg/builder/capabilities/HardwareCapabilitiesFactory.ts new file mode 100644 index 00000000..3bed8ee2 --- /dev/null +++ b/server/src/ffmpeg/builder/capabilities/HardwareCapabilitiesFactory.ts @@ -0,0 +1,37 @@ +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; +import { + BaseFfmpegHardwareCapabilities, + FfmpegHardwareCapabilitiesFactory, +} from '@/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.ts'; +import { DefaultHardwareCapabilities } from '@/ffmpeg/builder/capabilities/DefaultHardwareCapabilities.ts'; +import { NoHardwareCapabilities } from '@/ffmpeg/builder/capabilities/NoHardwareCapabilities.ts'; +import { NvidiaHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts'; +import { VaapiHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesFactory.ts'; +import { FfmpegSettings } from '@tunarr/types'; + +export class HardwareCapabilitiesFactory + implements FfmpegHardwareCapabilitiesFactory +{ + constructor( + private ffmpegSettings: FfmpegSettings, + private transcodeConfig: TranscodeConfig, + ) {} + + async getCapabilities(): Promise { + switch (this.transcodeConfig.hardwareAccelerationMode) { + case 'none': + return new NoHardwareCapabilities(); + case 'cuda': + return new NvidiaHardwareCapabilitiesFactory( + this.ffmpegSettings, + ).getCapabilities(); + case 'qsv': + case 'vaapi': + return new VaapiHardwareCapabilitiesFactory( + this.transcodeConfig, + ).getCapabilities(); + case 'videotoolbox': + return new DefaultHardwareCapabilities(); + } + } +} diff --git a/server/src/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts b/server/src/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts new file mode 100644 index 00000000..aa96912c --- /dev/null +++ b/server/src/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts @@ -0,0 +1,110 @@ +import { + BaseFfmpegHardwareCapabilities, + FfmpegHardwareCapabilitiesFactory, +} from '@/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.ts'; +import { NoHardwareCapabilities } from '@/ffmpeg/builder/capabilities/NoHardwareCapabilities.ts'; +import { NvidiaHardwareCapabilities } from '@/ffmpeg/builder/capabilities/NvidiaHardwareCapabilities.ts'; +import { ChildProcessHelper } from '@/util/ChildProcessHelper.ts'; +import { cacheGetOrSet } from '@/util/cache.ts'; +import dayjs from '@/util/dayjs.ts'; +import { LoggerFactory } from '@/util/logging/LoggerFactory.ts'; +import { FfmpegSettings } from '@tunarr/types'; +import { + attempt, + drop, + isEmpty, + isError, + map, + nth, + reject, + split, + trim, +} from 'lodash-es'; +import NodeCache from 'node-cache'; + +const NvidiaGpuArchPattern = /SM\s+(\d\.\d)/; +const NvidiaGpuModelPattern = /(GTX\s+[0-9a-zA-Z]+[\sTtIi]+)/; + +export class NvidiaHardwareCapabilitiesFactory + implements FfmpegHardwareCapabilitiesFactory +{ + private static logger = LoggerFactory.child({ + className: NvidiaHardwareCapabilitiesFactory.name, + }); + + private static cache = new NodeCache({ + stdTTL: +dayjs.duration({ hours: 1 }), + }); + + private static makeCacheKey(path: string, command: string): string { + return `${path}_${command}`; + } + + constructor(private settings: FfmpegSettings) {} + + async getCapabilities(): Promise { + const result = await attempt(async () => { + const out = await cacheGetOrSet( + NvidiaHardwareCapabilitiesFactory.cache, + NvidiaHardwareCapabilitiesFactory.makeCacheKey( + this.settings.ffmpegExecutablePath, + 'capabilities', + ), + () => + new ChildProcessHelper().getStdout( + this.settings.ffmpegExecutablePath, + [ + '-hide_banner', + '-f', + 'lavfi', + '-i', + 'nullsrc', + '-c:v', + 'h264_nvenc', + '-gpu', + 'list', + '-f', + 'null', + '-', + ], + true, + ), + ); + + const lines = reject(map(drop(split(out, '\n'), 1), trim), (s) => + isEmpty(s), + ); + + for (const line of lines) { + const archMatch = line.match(NvidiaGpuArchPattern); + if (archMatch) { + const archString = archMatch[1]; + const archNum = parseInt(archString.replaceAll('.', '')); + const model = + nth(line.match(NvidiaGpuModelPattern), 1)?.trim() ?? 'unknown'; + this.logger.debug( + `Detected NVIDIA GPU (model = "${model}", arch = "${archString}")`, + ); + return new NvidiaHardwareCapabilities(model, archNum); + } + } + + this.logger.warn('Could not parse ffmepg output for Nvidia capabilities'); + return new NoHardwareCapabilities(); + }); + + if (isError(result)) { + this.logger.warn( + result, + 'Error while attempting to determine Nvidia hardware capabilities', + ); + return new NoHardwareCapabilities(); + } + + return result; + } + + private get logger() { + return NvidiaHardwareCapabilitiesFactory.logger; + } +} diff --git a/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilities.ts b/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilities.ts index 1595c2b3..0b1134ba 100644 --- a/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilities.ts +++ b/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilities.ts @@ -3,7 +3,7 @@ import { PixelFormat } from '@/ffmpeg/builder/format/PixelFormat.ts'; import { RateControlMode } from '@/ffmpeg/builder/types.ts'; import { Maybe } from '@/types/util.ts'; import { isDefined } from '@/util/index.ts'; -import { find, isEmpty, some, split } from 'lodash-es'; +import { find, some } from 'lodash-es'; import { P, match } from 'ts-pattern'; import { BaseFfmpegHardwareCapabilities } from './BaseFfmpegHardwareCapabilities.ts'; @@ -50,59 +50,6 @@ export class VaapiProfileEntrypoint { } } -export class VaapiHardwareCapabilitiesFactory { - private static ProfileEntrypointPattern = /(VAProfile\w*).*(VAEntrypoint\w*)/; - private static ProfileRateControlPattern = /.*VA_RC_(\w*).*/; - - static extractEntrypointsFromVaInfo(result: string) { - const entrypoints: VaapiProfileEntrypoint[] = []; - for (const line of split(result, '\n')) { - const match = line.match(this.ProfileEntrypointPattern); - if (match) { - entrypoints.push(new VaapiProfileEntrypoint(match[1], match[2])); - } - } - - return entrypoints; - } - - static extractAllFromVaInfo(result: string) { - const entrypoints: VaapiProfileEntrypoint[] = []; - let currentEntrypoint: VaapiProfileEntrypoint | null = null; - - for (const line of split(result, '\n')) { - let match = line.match(this.ProfileEntrypointPattern); - if (match) { - currentEntrypoint = new VaapiProfileEntrypoint(match[1], match[2]); - entrypoints.push(currentEntrypoint); - } else if (currentEntrypoint) { - match = line.match(this.ProfileRateControlPattern); - if (match) { - switch (match[0]?.trim().toLowerCase()) { - case 'cgp': - currentEntrypoint.addRateControlMode(RateControlMode.CQP); - break; - case 'cbr': - currentEntrypoint.addRateControlMode(RateControlMode.CBR); - break; - case 'vbr': - currentEntrypoint.addRateControlMode(RateControlMode.VBR); - break; - default: - break; - } - } - } - } - - if (isEmpty(entrypoints)) { - return null; - } - - return new VaapiHardwareCapabilities(entrypoints); - } -} - export class VaapiHardwareCapabilities extends BaseFfmpegHardwareCapabilities { readonly type: string = 'vaapi'; diff --git a/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesFactory.ts b/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesFactory.ts new file mode 100644 index 00000000..80bc315e --- /dev/null +++ b/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesFactory.ts @@ -0,0 +1,87 @@ +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; +import { FfmpegHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/BaseFfmpegHardwareCapabilities.ts'; +import { DefaultHardwareCapabilities } from '@/ffmpeg/builder/capabilities/DefaultHardwareCapabilities.ts'; +import { NoHardwareCapabilities } from '@/ffmpeg/builder/capabilities/NoHardwareCapabilities.ts'; +import { VaapiHardwareCapabilitiesParser } from '@/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesParser.ts'; +import { ChildProcessHelper } from '@/util/ChildProcessHelper.ts'; +import { cacheGetOrSet } from '@/util/cache.ts'; +import dayjs from '@/util/dayjs.ts'; +import { isLinux, isNonEmptyString } from '@/util/index.ts'; +import { LoggerFactory } from '@/util/logging/LoggerFactory.ts'; +import { isEmpty, isNull, isUndefined } from 'lodash-es'; +import NodeCache from 'node-cache'; + +export class VaapiHardwareCapabilitiesFactory + implements FfmpegHardwareCapabilitiesFactory +{ + private static logger = LoggerFactory.child({ + className: VaapiHardwareCapabilitiesFactory.name, + }); + + private static cache = new NodeCache({ + stdTTL: +dayjs.duration({ hours: 1 }), + }); + + private static vaInfoCacheKey(driver: string, device: string) { + return `vainfo_${driver}_${device}`; + } + + constructor(private transcodeConfig: TranscodeConfig) {} + + async getCapabilities() { + const vaapiDevice = isNonEmptyString(this.transcodeConfig.vaapiDevice) + ? this.transcodeConfig.vaapiDevice + : isLinux() + ? '/dev/dri/renderD128' + : undefined; + + if (isUndefined(vaapiDevice) || isEmpty(vaapiDevice)) { + this.logger.error('Cannot detect VAAPI capabilities without a device'); + return new NoHardwareCapabilities(); + } + + // windows check bail! + if (process.platform === 'win32') { + return new DefaultHardwareCapabilities(); + } + + const driver = this.transcodeConfig.vaapiDriver ?? ''; + + return await cacheGetOrSet( + VaapiHardwareCapabilitiesFactory.cache, + VaapiHardwareCapabilitiesFactory.vaInfoCacheKey(vaapiDevice, driver), + async () => { + const result = await new ChildProcessHelper().getStdout( + 'vainfo', + ['--display', 'drm', '--device', vaapiDevice, '-a'], + false, + isNonEmptyString(driver) ? { LIBVA_DRIVER_NAME: driver } : undefined, + false, + ); + + if (!isNonEmptyString(result)) { + this.logger.warn( + 'Unable to find VAAPI capabilities via vainfo. Please make sure it is installed.', + ); + return new DefaultHardwareCapabilities(); + } + + try { + const capabilities = + VaapiHardwareCapabilitiesParser.extractAllFromVaInfo(result); + if (isNull(capabilities)) { + return new NoHardwareCapabilities(); + } + return capabilities; + } catch (e) { + this.logger.error(e, 'Error while detecting VAAPI capabilities.'); + return new NoHardwareCapabilities(); + } + }, + ); + } + + private get logger() { + return VaapiHardwareCapabilitiesFactory.logger; + } +} diff --git a/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesParser.ts b/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesParser.ts new file mode 100644 index 00000000..02bf38bf --- /dev/null +++ b/server/src/ffmpeg/builder/capabilities/VaapiHardwareCapabilitiesParser.ts @@ -0,0 +1,59 @@ +import { RateControlMode } from '@/ffmpeg/builder/types.ts'; +import { isEmpty, split } from 'lodash-es'; +import { + VaapiHardwareCapabilities, + VaapiProfileEntrypoint, +} from './VaapiHardwareCapabilities.ts'; + +export class VaapiHardwareCapabilitiesParser { + private static ProfileEntrypointPattern = /(VAProfile\w*).*(VAEntrypoint\w*)/; + private static ProfileRateControlPattern = /.*VA_RC_(\w*).*/; + + static extractEntrypointsFromVaInfo(result: string) { + const entrypoints: VaapiProfileEntrypoint[] = []; + for (const line of split(result, '\n')) { + const match = line.match(this.ProfileEntrypointPattern); + if (match) { + entrypoints.push(new VaapiProfileEntrypoint(match[1], match[2])); + } + } + + return entrypoints; + } + + static extractAllFromVaInfo(result: string) { + const entrypoints: VaapiProfileEntrypoint[] = []; + let currentEntrypoint: VaapiProfileEntrypoint | null = null; + + for (const line of split(result, '\n')) { + let match = line.match(this.ProfileEntrypointPattern); + if (match) { + currentEntrypoint = new VaapiProfileEntrypoint(match[1], match[2]); + entrypoints.push(currentEntrypoint); + } else if (currentEntrypoint) { + match = line.match(this.ProfileRateControlPattern); + if (match) { + switch (match[0]?.trim().toLowerCase()) { + case 'cgp': + currentEntrypoint.addRateControlMode(RateControlMode.CQP); + break; + case 'cbr': + currentEntrypoint.addRateControlMode(RateControlMode.CBR); + break; + case 'vbr': + currentEntrypoint.addRateControlMode(RateControlMode.VBR); + break; + default: + break; + } + } + } + } + + if (isEmpty(entrypoints)) { + return null; + } + + return new VaapiHardwareCapabilities(entrypoints); + } +} diff --git a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts index ffa31625..2a44aa58 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts @@ -1,14 +1,15 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; +import { HardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/HardwareCapabilitiesFactory.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { HardwareAccelerationMode } from '@/ffmpeg/builder/types.ts'; -import { FFMPEGInfo } from '@/ffmpeg/ffmpegInfo.ts'; +import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.ts'; import { Nullable } from '@/types/util.ts'; import { FfmpegSettings } from '@tunarr/types'; import { isUndefined } from 'lodash-es'; -import { DeepReadonly } from 'ts-essentials'; import { PipelineBuilder } from './PipelineBuilder.js'; import { NvidiaPipelineBuilder } from './hardware/NvidiaPipelineBuilder.ts'; import { QsvPipelineBuilder } from './hardware/QsvPipelineBuilder.ts'; @@ -19,10 +20,11 @@ import { SoftwarePipelineBuilder } from './software/SoftwarePipelineBuilder.ts'; export class PipelineBuilderFactory { constructor(private settingsDB: SettingsDB = getSettings()) {} - builder( - settings: DeepReadonly = this.settingsDB.ffmpegSettings(), - ): PipelineBuilderFactory$Builder { - return new PipelineBuilderFactory$Builder(settings); + builder(transcodeConfig: TranscodeConfig): PipelineBuilderFactory$Builder { + return new PipelineBuilderFactory$Builder( + this.settingsDB.ffmpegSettings(), + transcodeConfig, + ); } } @@ -33,7 +35,10 @@ class PipelineBuilderFactory$Builder { private watermarkInputSource: Nullable = null; private hardwareAccelerationMode: HardwareAccelerationMode = 'none'; - constructor(private ffmpegSettings: FfmpegSettings) {} + constructor( + private ffmpegSettings: FfmpegSettings, + private transcodeConfig: TranscodeConfig, + ) {} setVideoInputSource( videoInputSource: Nullable, @@ -73,12 +78,14 @@ class PipelineBuilderFactory$Builder { throw new Error(); } - const info = new FFMPEGInfo(this.ffmpegSettings); - const hardwareCapabilities = await info.getHardwareCapabilities( - this.hardwareAccelerationMode, - ); - const binaryCapabilities = await info.getCapabilities(); - + const info = new FfmpegInfo(this.ffmpegSettings); + const [hardwareCapabilities, binaryCapabilities] = await Promise.all([ + new HardwareCapabilitiesFactory( + this.ffmpegSettings, + this.transcodeConfig, + ).getCapabilities(), + info.getCapabilities(), + ]); switch (this.hardwareAccelerationMode) { case 'cuda': return new NvidiaPipelineBuilder( diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index ef579602..79295071 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -1,4 +1,9 @@ import { Channel } from '@/db/schema/Channel.ts'; +import { + HardwareAccelerationMode, + TranscodeConfig, + TranscodeVideoOutputFormat, +} from '@/db/schema/TranscodeConfig.ts'; import { serverOptions } from '@/globals.js'; import { ConcatSessionType } from '@/stream/Session.js'; import { Maybe, Nullable } from '@/types/util.js'; @@ -7,10 +12,8 @@ import { Logger, LoggerFactory } from '@/util/logging/LoggerFactory.js'; import { makeLocalUrl } from '@/util/serverUtil.js'; import { getTunarrVersion } from '@/util/version.js'; import { FfmpegSettings, Resolution, Watermark } from '@tunarr/types'; -import { - SupportedHardwareAccels, - SupportedVideoFormats, -} from '@tunarr/types/schemas'; + +import { NvidiaHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts'; import dayjs from 'dayjs'; import { Duration } from 'dayjs/plugin/duration.js'; import { first, isEmpty, isNil, isUndefined, merge, round } from 'lodash-es'; @@ -37,7 +40,7 @@ import { OutputFormat, } from './builder/constants.ts'; import { HlsWrapperOptions, IFFMPEG } from './ffmpegBase.ts'; -import { FFMPEGInfo } from './ffmpegInfo.js'; +import { FfmpegInfo } from './ffmpegInfo.js'; const MAXIMUM_ERROR_DURATION_MS = 60000; @@ -101,33 +104,33 @@ export const defaultConcatOptions: DeepRequired = { }; const hardwareAccelToEncoder: Record< - SupportedHardwareAccels, - Record + HardwareAccelerationMode, + Record > = { none: { h264: 'libx264', hevc: 'libx265', - mpeg2: 'mpeg2video', + mpeg2video: 'mpeg2video', }, cuda: { h264: 'h264_nvenc', hevc: 'hevc_nvenc', - mpeg2: 'h264_nvenc', // No mpeg2 video encoder + mpeg2video: 'h264_nvenc', // No mpeg2 video encoder }, qsv: { h264: 'h264_qsv', hevc: 'hevc_qsv', - mpeg2: 'mpeg2_qsv', + mpeg2video: 'mpeg2_qsv', }, vaapi: { h264: 'h264_vaapi', hevc: 'hevc_vaapi', - mpeg2: 'mpeg2_vaapi', + mpeg2video: 'mpeg2_vaapi', }, videotoolbox: { h264: 'h264_videotoolbox', hevc: 'hevc_videotoolbox', - mpeg2: 'h264_videotoolbox', + mpeg2video: 'h264_videotoolbox', }, }; @@ -158,10 +161,12 @@ export class FFMPEG implements IFFMPEG { private volumePercent: number; private hasBeenKilled: boolean = false; private alignAudio: boolean; - private capabilities: FFMPEGInfo; + private ffmpegInfo: FfmpegInfo; + private nvidiaCapabilities: NvidiaHardwareCapabilitiesFactory; constructor( private opts: DeepReadonly, + private transcodeConfig: TranscodeConfig, private channel: Channel, private audioOnly: boolean = false, ) { @@ -170,43 +175,16 @@ export class FFMPEG implements IFFMPEG { className: FFMPEG.name, channel: channel.uuid, }); - this.opts = opts; this.errorPicturePath = makeLocalUrl('/images/generic-error-screen.png'); this.ffmpegName = 'unnamed ffmpeg'; this.channel = channel; - this.capabilities = new FFMPEGInfo(this.opts); - - let targetResolution = opts.targetResolution; - if (!isUndefined(channel.transcoding?.targetResolution)) { - targetResolution = channel.transcoding.targetResolution; - } - - if ( - !isUndefined(channel.transcoding?.videoBitrate) && - channel.transcoding.videoBitrate !== 0 - ) { - this.opts = { - ...this.opts, - videoBitrate: channel.transcoding.videoBitrate, - }; - } - - if ( - !isUndefined(channel.transcoding?.videoBufferSize) && - channel.transcoding.videoBufferSize !== 0 - ) { - this.opts = { - ...this.opts, - videoBufferSize: channel.transcoding.videoBufferSize, - }; - } - - this.wantedW = targetResolution.widthPx; - this.wantedH = targetResolution.heightPx; - - this.apad = this.opts.normalizeAudio; + this.ffmpegInfo = new FfmpegInfo(this.opts); + this.nvidiaCapabilities = new NvidiaHardwareCapabilitiesFactory(this.opts); + this.wantedW = transcodeConfig.resolution.widthPx; + this.wantedH = transcodeConfig.resolution.heightPx; + this.apad = true; this.ensureResolution = this.opts.normalizeResolution; - this.volumePercent = this.opts.audioVolumePercent; + this.volumePercent = this.transcodeConfig.audioVolumePercent; } createConcatSession( @@ -240,7 +218,7 @@ export class FFMPEG implements IFFMPEG { ]; // Workaround until new pipeline is in place... - const scThreshold = this.opts.videoEncoder.includes('mpeg2') + const scThreshold = this.transcodeConfig.videoFormat.includes('mpeg2') ? '1000000000' : '0'; @@ -264,16 +242,16 @@ export class FFMPEG implements IFFMPEG { `0:a`, ...this.getVideoOutputOptions(), '-c:a', - this.opts.audioEncoder, + this.transcodeConfig.audioFormat, ...this.getAudioOutputOptions(), // This _seems_ to quell issues with non-monotonous DTS coming // from the input audio stream '-af', 'aselect=concatdec_select,aresample=async=1', - `-muxdelay`, - this.opts.concatMuxDelay.toString(), - `-muxpreload`, - this.opts.concatMuxDelay.toString(), + // `-muxdelay`, + // this.opts.concatMuxDelay.toString(), + // `-muxpreload`, + // this.opts.concatMuxDelay.toString(), `-metadata`, `service_provider="tunarr"`, `-metadata`, @@ -333,18 +311,6 @@ export class FFMPEG implements IFFMPEG { '1', '-readrate', '1', - // ...(streamMode === 'mpegts' - // ? [ - // '-safe', - // '0', - // '-stream_loop', - // '-1', - // `-protocol_whitelist`, - // `file,http,tcp,https,tcp,tls`, - // `-probesize`, - // '32', - // ] - // : []), '-i', streamUrl, '-map', @@ -390,7 +356,7 @@ export class FFMPEG implements IFFMPEG { outputFormat: OutputFormat = NutOutputFormat, ) { this.ffmpegName = 'Error Stream FFMPEG'; - if (this.opts.errorScreen === 'kill') { + if (this.transcodeConfig.errorScreen === 'kill') { throw new Error('Error screen configured to end stream. Ending now.'); } @@ -463,7 +429,7 @@ export class FFMPEG implements IFFMPEG { const ffmpegArgs: string[] = [ '-hide_banner', `-threads`, - this.opts.numThreads.toString(), + this.transcodeConfig.threadCount.toString(), `-fflags`, `+genpts+discardcorrupt+igndts`, '-loglevel', @@ -475,9 +441,9 @@ export class FFMPEG implements IFFMPEG { // Initialize like this because we're not checking whether or not // the input is hardware decodeable, yet. - if (this.opts.hardwareAccelerationMode === 'vaapi') { - const vaapiDevice = isNonEmptyString(this.opts.vaapiDevice) - ? this.opts.vaapiDevice + if (this.transcodeConfig.hardwareAccelerationMode === 'vaapi') { + const vaapiDevice = isNonEmptyString(this.transcodeConfig.vaapiDevice) + ? this.transcodeConfig.vaapiDevice : isLinux() ? '/dev/dri/renderD128' : undefined; @@ -506,7 +472,7 @@ export class FFMPEG implements IFFMPEG { let artificialBurst = false; if (!this.audioOnly || isNonEmptyString(streamSrc)) { - const supportsBurst = await this.capabilities.hasOption( + const supportsBurst = await this.ffmpegInfo.hasOption( 'readrate_initial_burst', ); @@ -581,7 +547,7 @@ export class FFMPEG implements IFFMPEG { // This is only tracked for the vaapi path at the moment let framesOnHardware = false; - if (this.opts.hardwareAccelerationMode === 'vaapi') { + if (this.transcodeConfig.hardwareAccelerationMode === 'vaapi') { videoComplex += `;${currentVideo}format=nv12|vaapi,hwupload=extra_hw_frames=64[hwupload]`; currentVideo = '[hwupload]'; framesOnHardware = true; @@ -595,20 +561,23 @@ export class FFMPEG implements IFFMPEG { // When adding filters, make sure that // videoComplex always begins wiht ; and doesn't end with ; - if ((videoStream?.framerate ?? 0) >= this.opts.maxFPS + 0.000001) { - videoComplex += `;${currentVideo}fps=${this.opts.maxFPS}[fpchange]`; + if (this.transcodeConfig.normalizeFrameRate && videoStream?.framerate) { + videoComplex += `;${currentVideo}fps=${videoStream?.framerate}[fpchange]`; currentVideo = '[fpchange]'; } // deinterlace if desired if ( videoStream?.scanType === 'interlaced' && - this.opts.deinterlaceFilter !== 'none' + this.transcodeConfig.deinterlaceVideo ) { - if (framesOnHardware && this.opts.hardwareAccelerationMode === 'vaapi') { + if ( + framesOnHardware && + this.transcodeConfig.hardwareAccelerationMode === 'vaapi' + ) { videoComplex += `;${currentVideo}deinterlace_vaapi=rate=field:auto=1[deinterlaced]`; } else { - videoComplex += `;${currentVideo}${this.opts.deinterlaceFilter}[deinterlaced]`; + videoComplex += `;${currentVideo}yadif=1[deinterlaced]`; } currentVideo = '[deinterlaced]'; } @@ -647,7 +616,7 @@ export class FFMPEG implements IFFMPEG { pic = isEmpty(this.channel.offline?.picture) ? defaultOfflinePic : this.channel.offline?.picture; - } else if (this.opts.errorScreen === 'pic') { + } else if (this.transcodeConfig.errorScreen === 'pic') { pic = this.errorPicturePath; } @@ -665,11 +634,11 @@ export class FFMPEG implements IFFMPEG { if (realtime) { filters.push('realtime'); } - if (this.opts.hardwareAccelerationMode === 'vaapi') { + if (this.transcodeConfig.hardwareAccelerationMode === 'vaapi') { filters.push('format=nv12', 'hwupload=extra_hw_frames=64'); } videoComplex = `;[${inputFiles++}:0]${filters.join(',')}[videox]`; - } else if (this.opts.errorScreen == 'static') { + } else if (this.transcodeConfig.errorScreen == 'static') { ffmpegArgs.push('-f', 'lavfi', '-i', `nullsrc=s=64x36`); let realtimePart = '[videox]'; if (realtime) { @@ -677,13 +646,13 @@ export class FFMPEG implements IFFMPEG { } videoComplex = `;geq=random(1)*255:128:128[videoz];[videoz]scale=${iW}:${iH}${realtimePart}`; inputFiles++; - } else if (this.opts.errorScreen == 'testsrc') { + } else if (this.transcodeConfig.errorScreen == 'testsrc') { ffmpegArgs.push('-f', 'lavfi', '-i', `testsrc=size=${iW}x${iH}`); videoComplex = `${realtime ? ';realtime' : ''}[videox]`; inputFiles++; } else if ( - this.opts.errorScreen == 'text' && + this.transcodeConfig.errorScreen == 'text' && streamSrc.type === 'error' ) { const sz2 = Math.ceil(iH / 33.0); @@ -713,7 +682,7 @@ export class FFMPEG implements IFFMPEG { if (streamSrc.type === 'offline' || streamSrc.type === 'error') { // silent audioComplex = `;aevalsrc=0:${durstr}:s=${ - this.opts.audioSampleRate * 1000 + this.transcodeConfig.audioSampleRate * 1000 },aresample=async=1:first_pts=0[audioy]`; if (streamSrc.type === 'offline') { if (isNonEmptyString(this.channel.offline?.soundtrack)) { @@ -723,12 +692,13 @@ export class FFMPEG implements IFFMPEG { audioComplex = `;[${inputFiles++}:a]aloop=loop=-1:size=2147483647[audioy]`; } } else if ( - this.opts.errorAudio === 'whitenoise' || - (!(this.opts.errorAudio === 'sine') && this.audioOnly) //when it's in audio-only mode, silent stream is confusing for errors. + this.transcodeConfig.errorScreenAudio === 'whitenoise' || + (!(this.transcodeConfig.errorScreenAudio === 'sine') && + this.audioOnly) //when it's in audio-only mode, silent stream is confusing for errors. ) { audioComplex = `;aevalsrc=random(0):${durstr}[audioy]`; this.volumePercent = Math.min(70, this.volumePercent); - } else if (this.opts.errorAudio === 'sine') { + } else if (this.transcodeConfig.errorScreenAudio === 'sine') { audioComplex = `;sine=f=440[audioy]`; this.volumePercent = Math.min(70, this.volumePercent); } @@ -759,7 +729,7 @@ export class FFMPEG implements IFFMPEG { // Resolution fix: Add scale filter, current stream becomes [siz] const beforeSizeChange = currentVideo; - const algo = this.opts.scalingAlgorithm; + const algo = 'bicubic'; // Scaling up - hardcode bicubic. let resizeMsg = ''; if ( !streamStats?.audioOnly && @@ -797,7 +767,7 @@ export class FFMPEG implements IFFMPEG { } let scaleFilter = `scale=${cw}:${ch}:flags=${algo},format=yuv420p`; - if (this.opts.hardwareAccelerationMode === 'vaapi') { + if (this.transcodeConfig.hardwareAccelerationMode === 'vaapi') { scaleFilter = `scale_vaapi=w=${cw}:h=${ch}:mode=fast:extra_hw_frames=64`; } @@ -843,14 +813,14 @@ export class FFMPEG implements IFFMPEG { currentVideo = `[${name}]`; iW = this.wantedW; iH = this.wantedH; - } else if (this.opts.hardwareAccelerationMode === 'cuda') { - const gpuCapabilities = await this.capabilities.getNvidiaCapabilities(); + } else if (this.transcodeConfig.hardwareAccelerationMode === 'cuda') { + const gpuCapabilities = await this.nvidiaCapabilities.getCapabilities(); // Use this as an analogue for detecting an attempt to encode 10-bit content // ... it might not be totally true, but we'll make this better. let canEncode = false; if (isSuccess(gpuCapabilities)) { canEncode = gpuCapabilities.canEncode( - this.opts.videoFormat, + this.transcodeConfig.videoFormat, first(streamStats?.videoDetails)?.profile, getPixelFormatForStream(streamStats!), ); @@ -957,7 +927,7 @@ export class FFMPEG implements IFFMPEG { : ''; const overlayShortest = watermark.animated ? 'shortest=1:' : ''; const overlayFilters = [`overlay=${overlayShortest}${position}${icnDur}`]; - if (this.opts.hardwareAccelerationMode === 'vaapi') { + if (this.transcodeConfig.hardwareAccelerationMode === 'vaapi') { overlayFilters.push('format=nv12', 'hwupload=extra_hw_frames=64'); } videoComplex += `;${currentVideo}${waterVideo}${overlayFilters.join( @@ -965,7 +935,7 @@ export class FFMPEG implements IFFMPEG { )}[comb]`; currentVideo = '[comb]'; } else { - if (this.opts.hardwareAccelerationMode === 'vaapi') { + if (this.transcodeConfig.hardwareAccelerationMode === 'vaapi') { if (!framesOnHardware) { videoComplex += `;${currentVideo}format=nv12,hwupload=extra_hw_frames=64[hwuploaded]`; currentVideo = '[hwuploaded]'; @@ -1025,7 +995,7 @@ export class FFMPEG implements IFFMPEG { //If there is a filter complex, add it. if (isNonEmptyString(filterComplex)) { - if (this.opts.hardwareAccelerationMode === 'vaapi') { + if (this.transcodeConfig.hardwareAccelerationMode === 'vaapi') { // ffmpegArgs.push('-filter_hw_device', 'hw'); } ffmpegArgs.push(`-filter_complex`, filterComplex.slice(1)); @@ -1172,7 +1142,7 @@ export class FFMPEG implements IFFMPEG { return [ '-g', - this.opts.hardwareAccelerationMode === 'qsv' + this.transcodeConfig.hardwareAccelerationMode === 'qsv' ? `${frameRate}` : `${frameRate * hlsOpts.hlsTime}`, '-keyint_min', @@ -1236,46 +1206,44 @@ export class FFMPEG implements IFFMPEG { ]; } - private getVideoOutputOptions() { + private getVideoOutputOptions(): string[] { // Right now we're just going to use a simple combo of videoFormat + hwAccel // to specify an encoder. There's a lot more we can do with these settings, // but we're going to hold off for the new ffmpeg pipeline implementation // and just keep existing behavior here. const videoEncoder = - hardwareAccelToEncoder[this.opts.hardwareAccelerationMode][ - this.opts.videoFormat + hardwareAccelToEncoder[this.transcodeConfig.hardwareAccelerationMode][ + this.transcodeConfig.videoFormat ]; return [ '-c:v', videoEncoder, `-b:v`, - `${this.opts.videoBitrate}k`, + `${this.transcodeConfig.videoBitRate}k`, `-maxrate:v`, - `${this.opts.videoBitrate}k`, + `${this.transcodeConfig.videoBitRate}k`, `-bufsize:v`, - `${this.opts.videoBufferSize}k`, + `${this.transcodeConfig.videoBufferSize}k`, ]; } private getAudioOutputOptions() { const audioOutputOpts = [ '-b:a', - `${this.opts.audioBitrate}k`, + `${this.transcodeConfig.audioBitRate}k`, '-maxrate:a', - `${this.opts.audioBitrate}k`, + `${this.transcodeConfig.audioBitRate}k`, '-bufsize:a', - `${this.opts.audioBufferSize}k`, + `${this.transcodeConfig.audioBufferSize}k`, ]; - if (this.opts.normalizeAudio) { - audioOutputOpts.push( - '-ac', - `${this.opts.audioChannels}`, - '-ar', - `${this.opts.audioSampleRate}k`, - ); - } + audioOutputOpts.push( + '-ac', + `${this.transcodeConfig.audioChannels}`, + '-ar', + `${this.transcodeConfig.audioSampleRate}k`, + ); return audioOutputOpts; } diff --git a/server/src/ffmpeg/ffmpegInfo.ts b/server/src/ffmpeg/ffmpegInfo.ts index c11bf64f..e01226d3 100644 --- a/server/src/ffmpeg/ffmpegInfo.ts +++ b/server/src/ffmpeg/ffmpegInfo.ts @@ -1,21 +1,17 @@ import { FfprobeMediaInfoSchema } from '@/types/ffmpeg.ts'; import { Result } from '@/types/result.ts'; import { Nullable } from '@/types/util.js'; +import { ChildProcessHelper } from '@/util/ChildProcessHelper.ts'; import { cacheGetOrSet } from '@/util/cache.js'; import dayjs from '@/util/dayjs.js'; -import { fileExists } from '@/util/fsUtil.js'; import { LoggerFactory } from '@/util/logging/LoggerFactory.ts'; -import { sanitizeForExec } from '@/util/strings.js'; import { seq } from '@tunarr/shared/util'; import { FfmpegSettings } from '@tunarr/types'; -import { ExecOptions, exec } from 'child_process'; import { drop, filter, isEmpty, isError, - isNull, - isUndefined, map, nth, reject, @@ -24,21 +20,9 @@ import { trim, } from 'lodash-es'; import NodeCache from 'node-cache'; -import PQueue from 'p-queue'; import { format } from 'util'; -import { - attempt, - isLinux, - isNonEmptyString, - parseIntOrNull, -} from '../util/index.ts'; -import { BaseFfmpegHardwareCapabilities } from './builder/capabilities/BaseFfmpegHardwareCapabilities.ts'; -import { DefaultHardwareCapabilities } from './builder/capabilities/DefaultHardwareCapabilities.js'; +import { attempt, isNonEmptyString, parseIntOrNull } from '../util/index.ts'; import { FfmpegCapabilities } from './builder/capabilities/FfmpegCapabilities.ts'; -import { NoHardwareCapabilities } from './builder/capabilities/NoHardwareCapabilities.js'; -import { NvidiaHardwareCapabilities } from './builder/capabilities/NvidiaHardwareCapabilities.js'; -import { VaapiHardwareCapabilitiesFactory } from './builder/capabilities/VaapiHardwareCapabilities.ts'; -import { HardwareAccelerationMode } from './builder/types.js'; const CacheKeys = { ENCODERS: 'encoders', @@ -62,16 +46,12 @@ export type FfmpegEncoder = { name: string; }; -const execQueue = new PQueue({ concurrency: 3 }); - const VersionExtractionPattern = /version\s+([^\s]+)\s+.*Copyright/; const VersionNumberExtractionPattern = /n?(\d+)\.(\d+)(\.(\d+))?[_\-.]*(.*)/; const CoderExtractionPattern = /[A-Z.]+\s([a-z0-9_-]+)\s*(.*)$/; const OptionsExtractionPattern = /^-([a-z_]+)\s+.*/; -const NvidiaGpuArchPattern = /SM\s+(\d\.\d)/; -const NvidiaGpuModelPattern = /(GTX\s+[0-9a-zA-Z]+[\sTtIi]+)/; -export class FFMPEGInfo { +export class FfmpegInfo { private logger = LoggerFactory.child({ caller: import.meta, className: this.constructor.name, @@ -89,14 +69,10 @@ export class FFMPEGInfo { return format(`${path}_${CacheKeys[command]}`, ...args); } - private static vaInfoCacheKey(driver: string, device: string) { - return `${CacheKeys.VAINFO}_${driver}_${device}`; - } - private ffmpegPath: string; private ffprobePath: string; - constructor(private opts: FfmpegSettings) { + constructor(opts: FfmpegSettings) { this.ffmpegPath = opts.ffmpegExecutablePath; this.ffprobePath = opts.ffprobeExecutablePath; } @@ -109,9 +85,6 @@ export class FFMPEGInfo { this.getAvailableVideoEncoders(), this.getHwAccels(), this.getOptions(), - ...(this.opts.hardwareAccelerationMode === 'cuda' - ? [this.getNvidiaCapabilities()] - : []), ]); } catch (e) { this.logger.error(e, 'Unexpected error during ffmpeg info seed'); @@ -185,7 +158,7 @@ export class FFMPEGInfo { async getAvailableAudioEncoders(): Promise> { return Result.attemptAsync(async () => { const out = await cacheGetOrSet( - FFMPEGInfo.resultCache, + FfmpegInfo.resultCache, this.cacheKey('ENCODERS'), () => this.getFfmpegStdout(['-hide_banner', '-encoders']), ); @@ -204,7 +177,7 @@ export class FFMPEGInfo { async getAvailableVideoEncoders(): Promise> { return Result.attemptAsync(async () => { const out = await cacheGetOrSet( - FFMPEGInfo.resultCache, + FfmpegInfo.resultCache, this.cacheKey('ENCODERS'), () => this.getFfmpegStdout(['-hide_banner', '-encoders']), ); @@ -233,7 +206,7 @@ export class FFMPEGInfo { async getHwAccels() { const res = await attempt(async () => { const out = await cacheGetOrSet( - FFMPEGInfo.resultCache, + FfmpegInfo.resultCache, this.cacheKey('HWACCELS'), () => this.getFfmpegStdout(['-hide_banner', '-hwaccels']), ); @@ -244,31 +217,10 @@ export class FFMPEGInfo { return isError(res) ? [] : res; } - async getHardwareCapabilities( - hwMode: HardwareAccelerationMode, - ): Promise { - // TODO Check for hw availability - // if (isEmpty(await this.getHwAccels())) { - - // } - - switch (hwMode) { - case 'none': - return new NoHardwareCapabilities(); - case 'cuda': - return await this.getNvidiaCapabilities(); - case 'qsv': - case 'vaapi': - return await this.getVaapiCapabilities(); - case 'videotoolbox': - return new DefaultHardwareCapabilities(); - } - } - async getOptions() { return Result.attemptAsync(async () => { const out = await cacheGetOrSet( - FFMPEGInfo.resultCache, + FfmpegInfo.resultCache, this.cacheKey('OPTIONS'), () => this.getFfmpegStdout(['-hide_banner', '-help', 'long']), ); @@ -295,117 +247,6 @@ export class FFMPEGInfo { return opts.get().includes(option) ? true : defaultOnMissing; } - async getNvidiaCapabilities() { - const result = await attempt(async () => { - const out = await cacheGetOrSet( - FFMPEGInfo.resultCache, - this.cacheKey('NVIDIA'), - () => - this.getFfmpegStdout( - [ - '-hide_banner', - '-f', - 'lavfi', - '-i', - 'nullsrc', - '-c:v', - 'h264_nvenc', - '-gpu', - 'list', - '-f', - 'null', - '-', - ], - true, - ), - ); - - const lines = reject(map(drop(split(out, '\n'), 1), trim), (s) => - isEmpty(s), - ); - - for (const line of lines) { - const archMatch = line.match(NvidiaGpuArchPattern); - if (archMatch) { - const archString = archMatch[1]; - const archNum = parseInt(archString.replaceAll('.', '')); - const model = - nth(line.match(NvidiaGpuModelPattern), 1)?.trim() ?? 'unknown'; - this.logger.debug( - `Detected NVIDIA GPU (model = "${model}", arch = "${archString}")`, - ); - return new NvidiaHardwareCapabilities(model, archNum); - } - } - - this.logger.warn('Could not parse ffmepg output for Nvidia capabilities'); - return new NoHardwareCapabilities(); - }); - - if (isError(result)) { - this.logger.warn( - result, - 'Error while attempting to determine Nvidia hardware capabilities', - ); - return new NoHardwareCapabilities(); - } - - return result; - } - - async getVaapiCapabilities() { - const vaapiDevice = isNonEmptyString(this.opts.vaapiDevice) - ? this.opts.vaapiDevice - : isLinux() - ? '/dev/dri/renderD128' - : undefined; - - if (isUndefined(vaapiDevice) || isEmpty(vaapiDevice)) { - this.logger.error('Cannot detect VAAPI capabilities without a device'); - return new NoHardwareCapabilities(); - } - - // windows check bail! - if (process.platform === 'win32') { - return new DefaultHardwareCapabilities(); - } - - const driver = this.opts.vaapiDriver ?? ''; - - return await cacheGetOrSet( - FFMPEGInfo.resultCache, - FFMPEGInfo.vaInfoCacheKey(vaapiDevice, driver), - async () => { - const result = await this.getStdout( - 'vainfo', - ['--display', 'drm', '--device', vaapiDevice, '-a'], - false, - isNonEmptyString(driver) ? { LIBVA_DRIVER_NAME: driver } : undefined, - false, - ); - - if (!isNonEmptyString(result)) { - this.logger.warn( - 'Unable to find VAAPI capabilities via vainfo. Please make sure it is installed.', - ); - return new DefaultHardwareCapabilities(); - } - - try { - const capabilities = - VaapiHardwareCapabilitiesFactory.extractAllFromVaInfo(result); - if (isNull(capabilities)) { - return new NoHardwareCapabilities(); - } - return capabilities; - } catch (e) { - this.logger.error(e, 'Error while detecting VAAPI capabilities.'); - return new NoHardwareCapabilities(); - } - }, - ); - } - async getCapabilities() { const [optionsResult, encodersResult] = await Promise.allSettled([ this.getOptions(), @@ -482,36 +323,16 @@ export class FFMPEGInfo { env?: NodeJS.ProcessEnv, isPath: boolean = true, ): Promise { - return execQueue.add( - async () => { - const sanitizedPath = sanitizeForExec(executable); - if (isPath && !(await fileExists(sanitizedPath))) { - throw new Error(`Path at ${sanitizedPath} does not exist`); - } - - const opts: ExecOptions = {}; - if (!isEmpty(env)) { - opts.env = env; - } - - return await new Promise((resolve, reject) => { - exec( - `"${sanitizedPath}" ${args.join(' ')}`, - opts, - function (error, stdout, stderr) { - if (error !== null && !swallowError) { - reject(error); - } - resolve(isNonEmptyString(stdout) ? stdout : stderr); - }, - ); - }); - }, - { throwOnTimeout: true }, + return new ChildProcessHelper().getStdout( + executable, + args, + swallowError, + env, + isPath, ); } private cacheKey(key: keyof typeof CacheKeys): string { - return FFMPEGInfo.makeCacheKey(this.ffmpegPath, key); + return FfmpegInfo.makeCacheKey(this.ffmpegPath, key); } } diff --git a/server/src/server.ts b/server/src/server.ts index f495a92a..786bbebf 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -28,7 +28,7 @@ import { apiRouter } from './api/index.js'; import { streamApi } from './api/streamApi.js'; import { videoApiRouter } from './api/videoApi.js'; import { getSettings } from './db/SettingsDB.ts'; -import { FFMPEGInfo } from './ffmpeg/ffmpegInfo.js'; +import { FfmpegInfo } from './ffmpeg/ffmpegInfo.js'; import { ServerOptions, initializeSingletons, @@ -121,7 +121,7 @@ export async function initServer(opts: ServerOptions) { } if (await fileExists(settingsDb.ffmpegSettings().ffmpegExecutablePath)) { - new FFMPEGInfo(settingsDb.ffmpegSettings()).seed().catch(() => {}); + new FfmpegInfo(settingsDb.ffmpegSettings()).seed().catch(() => {}); } scheduleJobs(ctx); diff --git a/server/src/services/health_checks/FfmpegVersionHealthCheck.ts b/server/src/services/health_checks/FfmpegVersionHealthCheck.ts index 06a2056e..2186bfea 100644 --- a/server/src/services/health_checks/FfmpegVersionHealthCheck.ts +++ b/server/src/services/health_checks/FfmpegVersionHealthCheck.ts @@ -1,5 +1,5 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; -import { FFMPEGInfo, FfmpegVersionResult } from '@/ffmpeg/ffmpegInfo.ts'; +import { FfmpegInfo, FfmpegVersionResult } from '@/ffmpeg/ffmpegInfo.ts'; import { fileExists } from '@/util/fsUtil.ts'; import { every, isNil, some } from 'lodash-es'; import { P, match } from 'ts-pattern'; @@ -50,7 +50,7 @@ export class FfmpegVersionHealthCheck implements HealthCheck { return warningResult; } - const info = new FFMPEGInfo(settings); + const info = new FfmpegInfo(settings); const version = await info.getVersion(); const ffmpegVersionError = this.isVersionValid(version, 'ffmpeg'); if (ffmpegVersionError) { diff --git a/server/src/services/health_checks/HardwareAccelerationHealthCheck.ts b/server/src/services/health_checks/HardwareAccelerationHealthCheck.ts index dce984ac..dc483e1d 100644 --- a/server/src/services/health_checks/HardwareAccelerationHealthCheck.ts +++ b/server/src/services/health_checks/HardwareAccelerationHealthCheck.ts @@ -1,5 +1,5 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; -import { FFMPEGInfo } from '@/ffmpeg/ffmpegInfo.ts'; +import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.ts'; import { SupportedHardwareAccels } from '@tunarr/types/schemas'; import { intersection, isEmpty, reject } from 'lodash-es'; import { @@ -16,7 +16,7 @@ export class HardwareAccelerationHealthCheck implements HealthCheck { async getStatus(): Promise { const supported = reject(SupportedHardwareAccels, (hw) => hw === 'none'); - const info = new FFMPEGInfo(this.settings.ffmpegSettings()); + const info = new FfmpegInfo(this.settings.ffmpegSettings()); const hwAccels = await info.getHwAccels(); if (intersection(supported, hwAccels).length === 0) { diff --git a/server/src/stream/ConcatSession.ts b/server/src/stream/ConcatSession.ts index 57b412c9..53715ba5 100644 --- a/server/src/stream/ConcatSession.ts +++ b/server/src/stream/ConcatSession.ts @@ -1,4 +1,4 @@ -import { Channel } from '@/db/schema/Channel.ts'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js'; import { isEmpty } from 'lodash-es'; import { ConcatStream } from './ConcatStream.ts'; @@ -14,7 +14,10 @@ export type ConcatSessionOptions = SessionOptions & { export class ConcatSession extends DirectStreamSession { #transcodeSession: FfmpegTranscodeSession; - constructor(channel: Channel, options: ConcatSessionOptions) { + constructor( + channel: ChannelWithTranscodeConfig, + options: ConcatSessionOptions, + ) { super(channel, options); this.on('removeConnection', () => { if (isEmpty(this.connections())) { @@ -32,17 +35,6 @@ export class ConcatSession extends DirectStreamSession { } protected async initializeStream(): Promise { - // let concatOptions: ConcatOptions; - // switch (this.sessionOptions.sessionType) { - // case 'hls_concat': - // concatOptions = { - // outputFormat: MpegTsOutputFormat, - // mode: ConcatStreamModeToChildMode[this.sessionOptions.sessionType] - // } - // case 'hls_slower_concat': - // case 'mpegts_concat': - // } - this.#transcodeSession = await new ConcatStream( this.channel, this.sessionOptions.sessionType, diff --git a/server/src/stream/ConcatStream.ts b/server/src/stream/ConcatStream.ts index 5ff3a9d3..99450596 100644 --- a/server/src/stream/ConcatStream.ts +++ b/server/src/stream/ConcatStream.ts @@ -1,5 +1,5 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; -import { Channel } from '@/db/schema/Channel.ts'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.ts'; import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.ts'; @@ -12,16 +12,17 @@ export class ConcatStream { #ffmpegSettings: FfmpegSettings; constructor( - private channel: Channel, + private channel: ChannelWithTranscodeConfig, private streamMode: ConcatSessionType, settings: SettingsDB = getSettings(), ) { this.#ffmpegSettings = settings.ffmpegSettings(); } - createSession(): Promise { + async createSession(): Promise { const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( this.#ffmpegSettings, + this.channel.transcodeConfig, this.channel, ); diff --git a/server/src/stream/DirectStreamSession.ts b/server/src/stream/DirectStreamSession.ts index 24ab6f61..55c71414 100644 --- a/server/src/stream/DirectStreamSession.ts +++ b/server/src/stream/DirectStreamSession.ts @@ -1,4 +1,4 @@ -import { Channel } from '@/db/schema/Channel.ts'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js'; import { once, round } from 'lodash-es'; import { Readable } from 'node:stream'; @@ -13,7 +13,7 @@ export abstract class DirectStreamSession< > extends Session { #stream: Readable; - protected constructor(channel: Channel, opts: TOpts) { + protected constructor(channel: ChannelWithTranscodeConfig, opts: TOpts) { super(channel, opts); } diff --git a/server/src/stream/OfflinePlayer.ts b/server/src/stream/OfflinePlayer.ts index da69c1ee..4cf6c5d6 100644 --- a/server/src/stream/OfflinePlayer.ts +++ b/server/src/stream/OfflinePlayer.ts @@ -48,6 +48,7 @@ export class OfflineProgramStream extends ProgramStream { try { const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( this.settingsDB.ffmpegSettings(), + this.context.transcodeConfig, this.context.channel, ); const lineupItem = this.context.lineupItem; diff --git a/server/src/stream/PlayerStreamContext.ts b/server/src/stream/PlayerStreamContext.ts index 5de10970..bae47f3a 100644 --- a/server/src/stream/PlayerStreamContext.ts +++ b/server/src/stream/PlayerStreamContext.ts @@ -1,5 +1,6 @@ import { StreamLineupItem } from '@/db/derived_types/StreamLineup.ts'; import { Channel } from '@/db/schema/Channel.ts'; +import { TranscodeConfig } from '@/db/schema/TranscodeConfig.ts'; import { GetCurrentLineupItemRequest } from './StreamProgramCalculator.ts'; export class PlayerContext { @@ -9,15 +10,17 @@ export class PlayerContext { public audioOnly: boolean, public isLoading: boolean, public realtime: boolean, - public useNewPipeline: boolean = false, + public useNewPipeline: boolean, + public transcodeConfig: TranscodeConfig, ) {} static error( duration: number, error: string | boolean | Error, channel: Channel, - realtime: boolean = true, - useNewPipeline: boolean = false, + realtime: boolean, + useNewPipeline: boolean, + transcodeConfig: TranscodeConfig, ): PlayerContext { return new PlayerContext( { @@ -32,6 +35,7 @@ export class PlayerContext { false, realtime, useNewPipeline, + transcodeConfig, ); } } diff --git a/server/src/stream/ProgramStream.ts b/server/src/stream/ProgramStream.ts index 23448e47..c38e47ef 100644 --- a/server/src/stream/ProgramStream.ts +++ b/server/src/stream/ProgramStream.ts @@ -136,6 +136,7 @@ export abstract class ProgramStream extends (events.EventEmitter as new () => Ty private getErrorStream(context: PlayerContext) { const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( this.settingsDB.ffmpegSettings(), + context.transcodeConfig, context.channel, ); @@ -155,7 +156,7 @@ export abstract class ProgramStream extends (events.EventEmitter as new () => Ty protected async getWatermark(): Promise> { const channel = this.context.channel; - if (this.settingsDB.ffmpegSettings().disableChannelOverlay) { + if (this.context.transcodeConfig.disableChannelOverlay) { return; } diff --git a/server/src/stream/Session.ts b/server/src/stream/Session.ts index 9b2e4d33..2a962c63 100644 --- a/server/src/stream/Session.ts +++ b/server/src/stream/Session.ts @@ -1,4 +1,4 @@ -import { Channel } from '@/db/schema/Channel.ts'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { TypedEventEmitter } from '@/types/eventEmitter.ts'; import { Result } from '@/types/result.js'; import { Maybe } from '@/types/util.js'; @@ -61,7 +61,7 @@ export abstract class Session< protected lock = new Mutex(); protected logger: Logger; protected sessionOptions: TOpts; - protected channel: Channel; + protected channel: ChannelWithTranscodeConfig; protected connectionTracker: ConnectionTracker; #state: SessionState = 'init'; @@ -69,7 +69,7 @@ export abstract class Session< error: Maybe; - constructor(channel: Channel, opts: TOpts) { + constructor(channel: ChannelWithTranscodeConfig, opts: TOpts) { super(); this.#uniqueId = v4(); this.logger = LoggerFactory.child({ diff --git a/server/src/stream/SessionManager.ts b/server/src/stream/SessionManager.ts index f6ba0162..ff8c2b36 100644 --- a/server/src/stream/SessionManager.ts +++ b/server/src/stream/SessionManager.ts @@ -1,5 +1,4 @@ import { ChannelDB } from '@/db/ChannelDB.ts'; -import { Channel } from '@/db/schema/Channel.ts'; import { Result } from '@/types/result.js'; import { Maybe } from '@/types/util.js'; import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; @@ -26,6 +25,7 @@ import { HlsSlowerSessionOptions, } from './hls/HlsSlowerSession.js'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { OnDemandChannelService } from '@/services/OnDemandChannelService.js'; import { ifDefined } from '@/util/index.js'; import { ChannelStreamMode } from '@tunarr/types'; @@ -217,7 +217,7 @@ export class SessionManager { token: string, connection: StreamConnectionDetails, sessionType: SessionType, - sessionFactory: (channel: Channel) => TSession, + sessionFactory: (channel: ChannelWithTranscodeConfig) => TSession, ): Promise> { const lock = await this.#sessionLocker.getOrCreateLock(channelId); try { @@ -228,7 +228,11 @@ export class SessionManager { ) as Maybe; if (isNil(session)) { - const channel = await this.channelDB.getChannel(channelId); + const channel = await this.channelDB + .getChannelBuilder(channelId) + .withTranscodeConfig() + .executeTakeFirst(); + if (isNil(channel)) { throw new ChannelNotFoundError(channelId); } diff --git a/server/src/stream/VideoStream.ts b/server/src/stream/VideoStream.ts index 44bbb6f6..5e08ccc8 100644 --- a/server/src/stream/VideoStream.ts +++ b/server/src/stream/VideoStream.ts @@ -64,7 +64,10 @@ export class VideoStream { const serverCtx = getServerContext(); const outStream = new PassThrough(); - const channel = await serverCtx.channelDB.getChannel(reqChannel); + const channel = await serverCtx.channelDB + .getChannelBuilder(reqChannel) + .withTranscodeConfig() + .executeTakeFirst(); if (isNil(channel)) { return { @@ -123,6 +126,8 @@ export class VideoStream { audioOnly, result.lineupItem.type === 'loading', true, + ffmpegSettings.useNewFfmpegPipeline, + channel.transcodeConfig, ); const programStream = ProgramStreamFactory.create( playerContext, diff --git a/server/src/stream/hls/BaseHlsSession.ts b/server/src/stream/hls/BaseHlsSession.ts index b98ec7c6..8aab359a 100644 --- a/server/src/stream/hls/BaseHlsSession.ts +++ b/server/src/stream/hls/BaseHlsSession.ts @@ -1,4 +1,4 @@ -import { Channel } from '@/db/schema/Channel.ts'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { Session, SessionOptions } from '@/stream/Session.ts'; import { Result } from '@/types/result.ts'; import { isNodeError } from '@/util/index.ts'; @@ -20,7 +20,7 @@ export abstract class BaseHlsSession< protected transcodedUntil: Dayjs; - constructor(channel: Channel, options: HlsSessionOptsT) { + constructor(channel: ChannelWithTranscodeConfig, options: HlsSessionOptsT) { super(channel, options); this._workingDirectory = path.resolve( diff --git a/server/src/stream/hls/HlsSession.ts b/server/src/stream/hls/HlsSession.ts index 2b998dd9..ecd34036 100644 --- a/server/src/stream/hls/HlsSession.ts +++ b/server/src/stream/hls/HlsSession.ts @@ -1,6 +1,6 @@ import { ChannelDB } from '@/db/ChannelDB.ts'; import { getSettings } from '@/db/SettingsDB.ts'; -import { Channel } from '@/db/schema/Channel.ts'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.ts'; import { GetLastPtsDurationTask } from '@/ffmpeg/GetLastPtsDuration.ts'; import { HlsOutputFormat } from '@/ffmpeg/builder/constants.ts'; @@ -34,7 +34,7 @@ export class HlsSession extends BaseHlsSession { #lastDelete: Dayjs = dayjs().subtract(1, 'year'); constructor( - channel: Channel, + channel: ChannelWithTranscodeConfig, options: HlsSessionOptions, programCalculator: StreamProgramCalculator = serverContext().streamProgramCalculator(), private settingsDB = getSettings(), @@ -153,7 +153,9 @@ export class HlsSession extends BaseHlsSession { false, result.lineupItem.type === 'loading', realtime, - this.sessionOptions.useNewPipeline, + this.sessionOptions.useNewPipeline ?? + this.settingsDB.ffmpegSettings().useNewFfmpegPipeline, + this.channel.transcodeConfig, ); let programStream = this.getProgramStream(context); @@ -182,6 +184,9 @@ export class HlsSession extends BaseHlsSession { transcodeSessionResult.error, result.channelContext, realtime, + this.sessionOptions.useNewPipeline ?? + this.settingsDB.ffmpegSettings().useNewFfmpegPipeline, + this.channel.transcodeConfig, ), ); diff --git a/server/src/stream/hls/HlsSlowerSession.ts b/server/src/stream/hls/HlsSlowerSession.ts index 336e21b4..85449357 100644 --- a/server/src/stream/hls/HlsSlowerSession.ts +++ b/server/src/stream/hls/HlsSlowerSession.ts @@ -1,5 +1,5 @@ import { getSettings } from '@/db/SettingsDB.ts'; -import { Channel } from '@/db/schema/Channel.ts'; +import { ChannelWithTranscodeConfig } from '@/db/schema/derivedTypes.js'; import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.ts'; import { defaultHlsOptions } from '@/ffmpeg/ffmpeg.ts'; @@ -35,12 +35,10 @@ export class HlsSlowerSession extends BaseHlsSession { // Start in lookahead mode #realtimeTranscode: boolean = false; #programCalculator: StreamProgramCalculator; - // #stream: VideoStreamResult; - #concatSession: FfmpegTranscodeSession; constructor( - channel: Channel, + channel: ChannelWithTranscodeConfig, options: HlsSlowerSessionOptions, programCalculator: StreamProgramCalculator = serverContext().streamProgramCalculator(), private settingsDB = getSettings(), @@ -81,6 +79,9 @@ export class HlsSlowerSession extends BaseHlsSession { request.audioOnly, lineupItem.type === 'loading', this.#realtimeTranscode, + this.sessionOptions.useNewPipeline ?? + this.settingsDB.ffmpegSettings().useNewFfmpegPipeline, + this.channel.transcodeConfig, ); let programStream = ProgramStreamFactory.create( @@ -102,6 +103,10 @@ export class HlsSlowerSession extends BaseHlsSession { result.lineupItem.streamDuration ?? result.lineupItem.duration, transcodeSessionResult.error, result.channelContext, + /*realtime=*/ true, + this.sessionOptions.useNewPipeline ?? + this.settingsDB.ffmpegSettings().useNewFfmpegPipeline, + this.channel.transcodeConfig, ), NutOutputFormat, ); @@ -149,6 +154,7 @@ export class HlsSlowerSession extends BaseHlsSession { const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( this.settingsDB.ffmpegSettings(), + this.channel.transcodeConfig, this.channel, ); diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index 292bbe1a..84c1d47a 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -54,8 +54,6 @@ export class JellyfinProgramStream extends ProgramStream { ); } - const ffmpegSettings = this.settingsDB.ffmpegSettings(); - const channel = this.context.channel; const server = await this.mediaSourceDB.findByType( MediaSourceType.Jellyfin, lineupItem.externalSourceId, @@ -77,8 +75,9 @@ export class JellyfinProgramStream extends ProgramStream { const watermark = await this.getWatermark(); this.ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( - ffmpegSettings, - channel, + this.settingsDB.ffmpegSettings(), + this.context.transcodeConfig, + this.context.channel, ); const stream = await jellyfinStreamDetails.getStream(lineupItem); diff --git a/server/src/stream/local/LocalFileStreamDetails.ts b/server/src/stream/local/LocalFileStreamDetails.ts index 63cb8886..9b0e1ffd 100644 --- a/server/src/stream/local/LocalFileStreamDetails.ts +++ b/server/src/stream/local/LocalFileStreamDetails.ts @@ -1,5 +1,5 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; -import { FFMPEGInfo } from '@/ffmpeg/ffmpegInfo.ts'; +import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.ts'; import { FfprobeAudioStream, FfprobeVideoStream } from '@/types/ffmpeg.ts'; import { Maybe, Nullable } from '@/types/util.ts'; import dayjs from '@/util/dayjs.ts'; @@ -30,7 +30,7 @@ export class LocalFileStreamDetails { return null; } - const ffmpegInfo = new FFMPEGInfo(this.settingsDB.ffmpegSettings()); + const ffmpegInfo = new FfmpegInfo(this.settingsDB.ffmpegSettings()); const probeResult = await ffmpegInfo.probeFile(this.path); diff --git a/server/src/stream/plex/PlexProgramStream.ts b/server/src/stream/plex/PlexProgramStream.ts index 36314ec9..e8c4a9a2 100644 --- a/server/src/stream/plex/PlexProgramStream.ts +++ b/server/src/stream/plex/PlexProgramStream.ts @@ -56,8 +56,6 @@ export class PlexProgramStream extends ProgramStream { ); } - const ffmpegSettings = this.settingsDB.ffmpegSettings(); - const channel = this.context.channel; const server = await this.mediaSourceDB.findByType( MediaSourceType.Plex, lineupItem.externalSourceId, @@ -79,8 +77,9 @@ export class PlexProgramStream extends ProgramStream { const watermark = await this.getWatermark(); const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( - ffmpegSettings, - channel, + this.settingsDB.ffmpegSettings(), + this.context.transcodeConfig, + this.context.channel, ); const stream = await plexStreamDetails.getStream(lineupItem); @@ -127,7 +126,7 @@ export class PlexProgramStream extends ProgramStream { this.updatePlexStatusTask = new UpdatePlexPlayStatusScheduledTask( server, { - channelNumber: channel.number, + channelNumber: this.context.channel.number, duration: lineupItem.duration, ratingKey: lineupItem.externalKey, startTime: lineupItem.start ?? 0, diff --git a/server/src/util/ChildProcessHelper.ts b/server/src/util/ChildProcessHelper.ts new file mode 100644 index 00000000..d7f5d885 --- /dev/null +++ b/server/src/util/ChildProcessHelper.ts @@ -0,0 +1,46 @@ +import { fileExists } from '@/util/fsUtil.ts'; +import { isNonEmptyString } from '@/util/index.ts'; +import { sanitizeForExec } from '@/util/strings.ts'; +import { isEmpty } from 'lodash-es'; +import { ExecOptions, exec } from 'node:child_process'; +import PQueue from 'p-queue'; + +export class ChildProcessHelper { + private static execQueue = new PQueue({ concurrency: 3 }); + + getStdout( + executable: string, + args: string[], + swallowError: boolean = false, + env?: NodeJS.ProcessEnv, + isPath: boolean = true, + ): Promise { + return ChildProcessHelper.execQueue.add( + async () => { + const sanitizedPath = sanitizeForExec(executable); + if (isPath && !(await fileExists(sanitizedPath))) { + throw new Error(`Path at ${sanitizedPath} does not exist`); + } + + const opts: ExecOptions = {}; + if (!isEmpty(env)) { + opts.env = env; + } + + return await new Promise((resolve, reject) => { + exec( + `"${sanitizedPath}" ${args.join(' ')}`, + opts, + function (error, stdout, stderr) { + if (error !== null && !swallowError) { + reject(error); + } + resolve(isNonEmptyString(stdout) ? stdout : stderr); + }, + ); + }); + }, + { throwOnTimeout: true }, + ); + } +}