From 3a739652cd112228f170faacb08eb01f2fc7f6e0 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Wed, 20 Nov 2024 07:45:16 -0500 Subject: [PATCH] fix: fix HLS concat on new FFmpeg pipeline Includes some other fixes around audio channels and padding on VAAPI Fixes #974 --- server/src/ffmpeg/FFmpegFactory.ts | 18 ++++++ server/src/ffmpeg/FfmpegStreamFactory.ts | 53 +++++++++++----- .../builder/filter/vaapi/ScaleVaapiFilter.ts | 6 +- .../builder/options/AudioOutputOptions.ts | 1 - .../ffmpeg/builder/options/OutputOption.ts | 10 ++- .../builder/pipeline/BasePipelineBuilder.ts | 61 +++++++++++++++--- .../builder/pipeline/PipelineBuilder.ts | 24 ++++++- .../pipeline/PipelineBuilderFactory.ts | 8 +-- .../hardware/NvidiaPipelineBuilder.ts | 4 +- .../pipeline/hardware/VaapiPipelineBuilder.ts | 2 +- .../hardware/VideoToolboxPipelineBuilder.ts | 2 +- server/src/ffmpeg/ffmpeg.ts | 41 ++++++------ server/src/ffmpeg/ffmpegBase.ts | 24 +++++-- server/src/stream/ConcatStream.ts | 62 +++++++++++-------- server/src/stream/ConcatWrapperStream.ts | 42 ------------- server/src/stream/OfflinePlayer.ts | 18 ++---- server/src/stream/ProgramStream.ts | 7 ++- server/src/stream/ProgramStreamFactory.ts | 4 +- server/src/stream/VideoStream.ts | 13 +++- server/src/stream/hls/HlsSlowerSession.ts | 19 +++++- .../stream/jellyfin/JellyfinProgramStream.ts | 10 ++- server/src/stream/plex/PlexProgramStream.ts | 11 ++-- 22 files changed, 276 insertions(+), 164 deletions(-) create mode 100644 server/src/ffmpeg/FFmpegFactory.ts delete mode 100644 server/src/stream/ConcatWrapperStream.ts diff --git a/server/src/ffmpeg/FFmpegFactory.ts b/server/src/ffmpeg/FFmpegFactory.ts new file mode 100644 index 000000000..9f7f1e656 --- /dev/null +++ b/server/src/ffmpeg/FFmpegFactory.ts @@ -0,0 +1,18 @@ +import { Channel } from '@/db/schema/Channel.ts'; +import { FfmpegSettings } from '@tunarr/types'; +import { FfmpegStreamFactory } from './FfmpegStreamFactory.ts'; +import { FFMPEG } from './ffmpeg.ts'; +import { IFFMPEG } from './ffmpegBase.ts'; + +export class FFmpegFactory { + static getFFmpegPipelineBuilder( + settings: FfmpegSettings, + channel: Channel, + ): IFFMPEG { + if (settings.useNewFfmpegPipeline) { + return new FfmpegStreamFactory(settings, channel); + } else { + return new FFMPEG(settings, channel); + } + } +} diff --git a/server/src/ffmpeg/FfmpegStreamFactory.ts b/server/src/ffmpeg/FfmpegStreamFactory.ts index 75dceb3ad..768631097 100644 --- a/server/src/ffmpeg/FfmpegStreamFactory.ts +++ b/server/src/ffmpeg/FfmpegStreamFactory.ts @@ -39,7 +39,11 @@ import { FfmpegState } from './builder/state/FfmpegState.ts'; import { FrameState } from './builder/state/FrameState.ts'; import { FrameSize } from './builder/types.ts'; import { ConcatOptions, StreamSessionOptions } from './ffmpeg.ts'; -import { IFFMPEG } from './ffmpegBase.ts'; +import { + ConcatStreamModeToChildMode, + HlsWrapperOptions, + IFFMPEG, +} from './ffmpegBase.ts'; import { FFMPEGInfo } from './ffmpegInfo.ts'; export class FfmpegStreamFactory extends IFFMPEG { @@ -57,7 +61,7 @@ export class FfmpegStreamFactory extends IFFMPEG { async createConcatSession( streamUrl: string, - opts: DeepReadonly>, + opts: DeepReadonly, ): Promise { const concatInput = new ConcatInputSource( new HttpStreamSource(streamUrl), @@ -73,7 +77,8 @@ export class FfmpegStreamFactory extends IFFMPEG { const calculator = new FfmpegPlaybackParamsCalculator(this.ffmpegSettings); - const pipeline = pipelineBuilder.build( + const pipeline = pipelineBuilder.concat( + concatInput, FfmpegState.create({ version: await this.ffmpegInfo.getVersion(), outputFormat: opts.outputFormat ?? MpegTsOutputFormat, @@ -81,16 +86,16 @@ export class FfmpegStreamFactory extends IFFMPEG { metadataServiceName: this.channel.name, ptsOffset: 0, }), - new FrameState({ - ...calculator.calculateForHlsConcat(), - scaledSize: FrameSize.fromResolution( - this.ffmpegSettings.targetResolution, - ), - paddedSize: FrameSize.fromResolution( - this.ffmpegSettings.targetResolution, - ), - isAnamorphic: false, - }), + // new FrameState({ + // ...calculator.calculateForHlsConcat(), + // scaledSize: FrameSize.fromResolution( + // this.ffmpegSettings.targetResolution, + // ), + // paddedSize: FrameSize.fromResolution( + // this.ffmpegSettings.targetResolution, + // ), + // isAnamorphic: false, + // }), ); return new FfmpegTranscodeSession( @@ -105,8 +110,9 @@ export class FfmpegStreamFactory extends IFFMPEG { ); } - async createWrapperConcatSession( + async createHlsWrapperSession( streamUrl: string, + opts: HlsWrapperOptions, ): Promise { const concatInput = new ConcatInputSource( new HttpStreamSource(streamUrl), @@ -116,12 +122,21 @@ export class FfmpegStreamFactory extends IFFMPEG { }), ); + switch (ConcatStreamModeToChildMode[opts.mode]) { + case 'hls': + return this.createHlsConcatSession(concatInput); + case 'hls_slower': + return this.createHlsSlowerConcatSession(concatInput); + } + } + + private async createHlsConcatSession(concatInput: ConcatInputSource) { const pipelineBuilder = await this.pipelineBuilderFactory .builder(this.ffmpegSettings) .setConcatInputSource(concatInput) .build(); - const pipeline = pipelineBuilder.hlsConcat( + const pipeline = pipelineBuilder.hlsWrap( concatInput, FfmpegState.forConcat( await this.ffmpegInfo.getVersion(), @@ -141,6 +156,12 @@ export class FfmpegStreamFactory extends IFFMPEG { ); } + private async createHlsSlowerConcatSession( + concatInput: ConcatInputSource, + ): Promise { + throw new Error('not yet implemented'); + } + async createStreamSession({ // TODO Fix these dumb params streamSource, @@ -234,7 +255,7 @@ export class FfmpegStreamFactory extends IFFMPEG { AudioStream.create({ index: isNaN(audioStreamIndex) ? 1 : audioStreamIndex, codec: audioStream.codec ?? 'unknown', - channels: playbackParams.audioChannels ?? -2, + channels: audioStream.channels ?? -2, }), ], audioState, diff --git a/server/src/ffmpeg/builder/filter/vaapi/ScaleVaapiFilter.ts b/server/src/ffmpeg/builder/filter/vaapi/ScaleVaapiFilter.ts index a304c776e..cb97de373 100644 --- a/server/src/ffmpeg/builder/filter/vaapi/ScaleVaapiFilter.ts +++ b/server/src/ffmpeg/builder/filter/vaapi/ScaleVaapiFilter.ts @@ -10,9 +10,12 @@ export class ScaleVaapiFilter extends FilterOption { private paddedSize: FrameSize, ) { super(); + this.filter = this.genFilter(); } - public get filter(): string { + public readonly filter: string; + + private genFilter(): string { let scale = ''; if (this.currentState.scaledSize.equals(this.scaledSize)) { @@ -23,6 +26,7 @@ export class ScaleVaapiFilter extends FilterOption { let aspectRatio = ''; if (!this.scaledSize.equals(this.paddedSize)) { // Set cropped size + aspectRatio = ':force_original_aspect_ratio=decrease'; } let squareScale = ''; diff --git a/server/src/ffmpeg/builder/options/AudioOutputOptions.ts b/server/src/ffmpeg/builder/options/AudioOutputOptions.ts index cc7bb64d3..6ebae2409 100644 --- a/server/src/ffmpeg/builder/options/AudioOutputOptions.ts +++ b/server/src/ffmpeg/builder/options/AudioOutputOptions.ts @@ -6,7 +6,6 @@ export const AudioChannelsOutputOption = ( desiredChannels: number, ) => { const opts: string[] = []; - // TODO Audio format constants if ( sourceChannels !== desiredChannels || (audioFormat === 'aac' && desiredChannels > 2) diff --git a/server/src/ffmpeg/builder/options/OutputOption.ts b/server/src/ffmpeg/builder/options/OutputOption.ts index 263dec48c..92957c078 100644 --- a/server/src/ffmpeg/builder/options/OutputOption.ts +++ b/server/src/ffmpeg/builder/options/OutputOption.ts @@ -57,6 +57,9 @@ export const MetadataServiceNameOutputOption = (serviceName: string) => export const DoNotMapMetadataOutputOption = () => makeConstantOutputOption(['-map_metadata', '-1']); +export const MapAllStreamsOutputOption = () => + makeConstantOutputOption(['-map', '0']); + export const NoSceneDetectOutputOption = ( value: number, ): ConstantOutputOption => @@ -90,12 +93,13 @@ export const FrameRateOutputOption = ( export const VideoTrackTimescaleOutputOption = (scale: number) => makeConstantOutputOption(['-video_track_timescale', scale.toString()]); -export const MpegTsOutputFormatOption = () => +export const MpegTsOutputFormatOption = (initialDiscontinuity?: boolean) => makeConstantOutputOption([ '-f', 'mpegts', - '-mpegts_flags', - '+initial_discontinuity', + ...(initialDiscontinuity + ? ['-mpegts_flags', '+initial_discontinuity'] + : []), ]); export const Mp4OutputFormatOption = () => diff --git a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts index 5ab425c3f..1e7a33a0a 100644 --- a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts @@ -81,6 +81,7 @@ import { DoNotMapMetadataOutputOption, FastStartOutputOption, FrameRateOutputOption, + MapAllStreamsOutputOption, MatroskaOutputFormatOption, MetadataServiceNameOutputOption, MetadataServiceProviderOutputOption, @@ -169,18 +170,23 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { protected context: PipelineBuilderContext; constructor( - protected videoInputSource: VideoInputSource, + protected nullableVideoInputSource: Nullable, private audioInputSource: Nullable, protected watermarkInputSource: Nullable, protected concatInputSource: Nullable, protected ffmpegCapabilities: FfmpegCapabilities, ) {} + get videoInputSource(): VideoInputSource { + // Only use this on video pipelines!!! + return this.nullableVideoInputSource!; + } + validate(): Nullable { return null; } - hlsConcat(input: ConcatInputSource, state: FfmpegState) { + concat(input: ConcatInputSource, state: FfmpegState) { const pipelineSteps: PipelineStep[] = [ new NoStdInOption(), new HideBannerOption(), @@ -203,10 +209,8 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { input.addOption(new ConcatHttpReconnectOptions()); } - if (this.ffmpegState.threadCount) { - pipelineSteps.unshift( - new ThreadCountOption(this.ffmpegState.threadCount), - ); + if (state.threadCount) { + pipelineSteps.unshift(new ThreadCountOption(state.threadCount)); } pipelineSteps.push(NoSceneDetectOutputOption(0), new CopyAllEncoder()); @@ -231,6 +235,46 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { }); } + hlsWrap(input: ConcatInputSource, state: FfmpegState) { + const pipelineSteps: PipelineStep[] = [ + new NoStdInOption(), + new HideBannerOption(), + new ThreadCountOption(1), + new LogLevelOption(state.logLevel), + new NoStatsOption(), + new StandardFormatFlags(), + MapAllStreamsOutputOption(), + new CopyAllEncoder(), + ]; + + if (input.protocol === 'http') { + input.addOption(new ConcatHttpReconnectOptions()); + } + + input.addOption(new ReadrateInputOption(this.ffmpegCapabilities, 0)); + if (state.metadataServiceName) { + pipelineSteps.push( + MetadataServiceNameOutputOption(state.metadataServiceName), + ); + } + if (state.metadataServiceProvider) { + pipelineSteps.push( + MetadataServiceProviderOutputOption(state.metadataServiceProvider), + ); + } + pipelineSteps.push( + MpegTsOutputFormatOption(false), + PipeProtocolOutputOption(), + ); + + return new Pipeline(pipelineSteps, { + videoInput: null, + audioInput: null, + concatInput: input, + watermarkInput: null, + }); + } + build(ffmpegState: FfmpegState, desiredState: FrameState): Pipeline { this.context = { videoStream: first(this.videoInputSource.streams), @@ -284,6 +328,10 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { this.pipelineSteps.push(TimeLimitOutputOption(this.ffmpegState.duration)); } + if (isNull(this.nullableVideoInputSource)) { + throw new Error('FFmpeg pipeline currently requires a video input'); + } + if ( this.videoInputSource.protocol === 'http' && this.videoInputSource.continuity === 'discrete' @@ -585,7 +633,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { isNonEmptyString(this.ffmpegState.hlsSegmentTemplate) && isNonEmptyString(this.ffmpegState.hlsBaseStreamUrl) ) { - console.log(this.ffmpegState); this.pipelineSteps.push( new HlsOutputFormat( this.desiredState, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/PipelineBuilder.ts index 76e3e7dda..5cc5c0eaa 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineBuilder.ts @@ -6,6 +6,28 @@ import { Pipeline } from './Pipeline.ts'; export interface PipelineBuilder { validate(): Nullable; - hlsConcat(input: ConcatInputSource, state: FfmpegState): Pipeline; + + /** + * Takes input source in ffconcat format and returns the concatenated output stream + * in the given output format. Simply copies input streams. + * @param input + * @param state + */ + concat(input: ConcatInputSource, state: FfmpegState): Pipeline; + + /** + * Takes m3u8 HLS playlist as input and returns a continuous output stream + * in the given output format. Simple copies input streams. + * @param input + * @param state + */ + hlsWrap(input: ConcatInputSource, state: FfmpegState): Pipeline; + + /** + * Calculates an ffmpeg pipeline using the inputted ffmpeg state and desired + * output state. + * @param currentState + * @param desiredState + */ build(currentState: FfmpegState, desiredState: FrameState): Pipeline; } diff --git a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts index ba7e950f0..5f847c94c 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts @@ -7,7 +7,7 @@ import { HardwareAccelerationMode } from '@/ffmpeg/builder/types.ts'; import { FFMPEGInfo } from '@/ffmpeg/ffmpegInfo.ts'; import { Nullable } from '@/types/util.ts'; import { FfmpegSettings } from '@tunarr/types'; -import { isNull, isUndefined } from 'lodash-es'; +import { isUndefined } from 'lodash-es'; import { DeepReadonly } from 'ts-essentials'; import { PipelineBuilder } from './PipelineBuilder.js'; import { NvidiaPipelineBuilder } from './hardware/NvidiaPipelineBuilder.ts'; @@ -78,12 +78,6 @@ class PipelineBuilderFactory$Builder { ); const binaryCapabilities = await info.getCapabilities(); - if (isNull(this.videoInputSource)) { - // Audio-only pipeline builder?? - throw new Error('Not yet implemented'); - // return new SoftwarePipelineBuilder() - } - switch (this.hardwareAccelerationMode) { case 'cuda': return new NvidiaPipelineBuilder( diff --git a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts index 2f466e1b6..f0bf5ed26 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts @@ -49,7 +49,7 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { constructor( private hardwareCapabilities: BaseFfmpegHardwareCapabilities, binaryCapabilities: FfmpegCapabilities, - videoInputFile: VideoInputSource, + videoInputFile: Nullable, audioInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, @@ -358,7 +358,6 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { this.watermarkInputSource && currentState.frameDataLocation === FrameDataLocation.Hardware ) { - this.logger.debug('Using software encoder'); const hwDownloadFilter = new HardwareDownloadCudaFilter( currentState.pixelFormat, null, @@ -367,7 +366,6 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { steps.push(hwDownloadFilter); } - console.debug('pixelFormat', currentState, this.ffmpegState); if ( currentState.frameDataLocation === FrameDataLocation.Hardware && this.ffmpegState.encoderHwAccelMode === HardwareAccelerationMode.None diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts index b221a8961..0d9c72570 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts @@ -58,7 +58,7 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { constructor( private hardwareCapabilities: BaseFfmpegHardwareCapabilities, binaryCapabilities: FfmpegCapabilities, - videoInputFile: VideoInputSource, + videoInputFile: Nullable, audioInputFile: Nullable, watermarkInputSource: Nullable, concatInputSource: Nullable, diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts index 048ea4426..1bc02cc39 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts @@ -25,7 +25,7 @@ export class VideoToolboxPipelineBuilder extends SoftwarePipelineBuilder { constructor( private hardwareCapabilities: BaseFfmpegHardwareCapabilities, binaryCapabilities: FfmpegCapabilities, - videoInputFile: VideoInputSource, + videoInputFile: Nullable, audioInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index aa7f5de76..01e168fa9 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -6,12 +6,7 @@ import { gcd } from '@/util/index.ts'; import { Logger, LoggerFactory } from '@/util/logging/LoggerFactory.js'; import { makeLocalUrl } from '@/util/serverUtil.js'; import { getTunarrVersion } from '@/util/version.js'; -import { - ChannelStreamMode, - FfmpegSettings, - Resolution, - Watermark, -} from '@tunarr/types'; +import { FfmpegSettings, Resolution, Watermark } from '@tunarr/types'; import { SupportedHardwareAccels, SupportedVideoFormats, @@ -41,7 +36,7 @@ import { NutOutputFormat, OutputFormat, } from './builder/constants.ts'; -import { IFFMPEG } from './ffmpegBase.ts'; +import { HlsWrapperOptions, IFFMPEG } from './ffmpegBase.ts'; import { FFMPEGInfo } from './ffmpegInfo.js'; const MAXIMUM_ERROR_DURATION_MS = 60000; @@ -218,7 +213,7 @@ export class FFMPEG implements IFFMPEG { createConcatSession( streamUrl: string, - opts: DeepReadonly> = defaultConcatOptions, + opts: DeepReadonly, ): Promise { this.ffmpegName = 'Concat FFMPEG'; const ffmpegArgs: string[] = [ @@ -316,7 +311,11 @@ export class FFMPEG implements IFFMPEG { return Promise.resolve(this.createProcess(ffmpegArgs)); } - createWrapperConcatSession(streamUrl: string, streamMode: ChannelStreamMode) { + createHlsWrapperSession(streamUrl: string, opts: HlsWrapperOptions) { + if (opts.mode === 'hls_slower_concat') { + return this.createConcatSession(streamUrl, opts); + } + this.ffmpegName = 'Concat Wrapper FFMPEG'; const ffmpegArgs = [ '-nostdin', @@ -336,18 +335,18 @@ 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', - ] - : []), + // ...(streamMode === 'mpegts' + // ? [ + // '-safe', + // '0', + // '-stream_loop', + // '-1', + // `-protocol_whitelist`, + // `file,http,tcp,https,tcp,tls`, + // `-probesize`, + // '32', + // ] + // : []), '-i', streamUrl, '-map', diff --git a/server/src/ffmpeg/ffmpegBase.ts b/server/src/ffmpeg/ffmpegBase.ts index 043375b41..840ebe877 100644 --- a/server/src/ffmpeg/ffmpegBase.ts +++ b/server/src/ffmpeg/ffmpegBase.ts @@ -1,20 +1,32 @@ +import { ConcatSessionType } from '@/stream/Session.ts'; import { Maybe } from '@/types/util.ts'; -import { ChannelStreamMode } from '@tunarr/types'; import { Duration } from 'dayjs/plugin/duration.js'; -import { DeepReadonly } from 'ts-essentials'; +import { DeepReadonly, StrictExclude } from 'ts-essentials'; import { FfmpegTranscodeSession } from './FfmpegTrancodeSession.ts'; import { OutputFormat } from './builder/constants.ts'; import { ConcatOptions, StreamSessionOptions } from './ffmpeg.ts'; +export type HlsWrapperOptions = DeepReadonly< + Omit & { + mode: StrictExclude; + } +>; + +export const ConcatStreamModeToChildMode = { + hls_concat: 'hls', + hls_slower_concat: 'hls_slower', + mpegts_concat: 'mpegts', +} as const; + export abstract class IFFMPEG { abstract createConcatSession( streamUrl: string, - opts: DeepReadonly>, + opts: DeepReadonly, ): Promise; - abstract createWrapperConcatSession( + abstract createHlsWrapperSession( streamUrl: string, - streamMode: ChannelStreamMode, + opts: HlsWrapperOptions, ): Promise; abstract createStreamSession( @@ -33,5 +45,7 @@ export abstract class IFFMPEG { abstract createOfflineSession( duration: Duration, outputFormat: OutputFormat, + ptsOffset?: number, + realtime?: boolean, ): Promise>; } diff --git a/server/src/stream/ConcatStream.ts b/server/src/stream/ConcatStream.ts index ad6226bdf..f2cc4a779 100644 --- a/server/src/stream/ConcatStream.ts +++ b/server/src/stream/ConcatStream.ts @@ -1,14 +1,14 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { Channel } from '@/db/schema/Channel.ts'; -import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.ts'; +import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.ts'; -import { ConcatOptions, FFMPEG } from '@/ffmpeg/ffmpeg.ts'; +import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.ts'; +import { ConcatOptions } from '@/ffmpeg/ffmpeg.ts'; +import { ConcatStreamModeToChildMode } from '@/ffmpeg/ffmpegBase.ts'; import { makeFfmpegPlaylistUrl, makeLocalUrl } from '@/util/serverUtil.js'; -import { ChannelStreamMode, FfmpegSettings } from '@tunarr/types'; -import { initial } from 'lodash-es'; +import { FfmpegSettings } from '@tunarr/types'; type ConcatStreamOptions = { - parentProcessType: 'hls' | 'direct'; audioOnly?: boolean; }; @@ -17,7 +17,7 @@ export class ConcatStream { constructor( private channel: Channel, - private concatOptions?: Partial, + private concatOptions: ConcatOptions & ConcatStreamOptions, settings: SettingsDB = getSettings(), ) { this.#ffmpegSettings = settings.ffmpegSettings(); @@ -25,32 +25,40 @@ export class ConcatStream { createSession(): Promise { const mode = this.concatOptions?.mode ?? 'mpegts_concat'; - // TODO... this is SO hacky - const childStreamMode = initial(mode.split('_')).join( - '_', - ) as ChannelStreamMode; - let concatUrl: string; + const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( + this.#ffmpegSettings, + this.channel, + ); + switch (mode) { // If we're wrapping an HLS stream, direct the concat process to // the m3u8 URL case 'hls_concat': - case 'hls_slower_concat': - concatUrl = makeLocalUrl(`/stream/channels/${this.channel.uuid}.m3u8`, { - mode: childStreamMode, - }); - break; + case 'hls_slower_concat': { + const childStreamMode = ConcatStreamModeToChildMode[mode]; + return ffmpeg.createHlsWrapperSession( + makeLocalUrl(`/stream/channels/${this.channel.uuid}.m3u8`, { + mode: childStreamMode, + }), + { + ...this.concatOptions, + mode, + }, + ); + } case 'mpegts_concat': - concatUrl = makeFfmpegPlaylistUrl({ - channel: this.channel.uuid, - audioOnly: this.concatOptions?.audioOnly ?? false, - mode: childStreamMode, - }); - break; + return ffmpeg.createConcatSession( + makeFfmpegPlaylistUrl({ + channel: this.channel.uuid, + audioOnly: this.concatOptions?.audioOnly ?? false, + mode: 'mpegts', + }), + { + ...this.concatOptions, + mode, + outputFormat: MpegTsOutputFormat, + }, + ); } - - const ffmpeg = this.#ffmpegSettings.useNewFfmpegPipeline - ? new FfmpegStreamFactory(this.#ffmpegSettings, this.channel) - : new FFMPEG(this.#ffmpegSettings, this.channel); - return ffmpeg.createWrapperConcatSession(concatUrl, childStreamMode); } } diff --git a/server/src/stream/ConcatWrapperStream.ts b/server/src/stream/ConcatWrapperStream.ts deleted file mode 100644 index 76976a0e7..000000000 --- a/server/src/stream/ConcatWrapperStream.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; -import { Channel } from '@/db/schema/Channel.ts'; -import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.ts'; -import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js'; -import { ConcatOptions, FFMPEG } from '@/ffmpeg/ffmpeg.js'; -import { makeLocalUrl } from '@/util/serverUtil.js'; -import { ChannelStreamMode, FfmpegSettings } from '@tunarr/types'; -import { StrictExtract } from 'ts-essentials'; - -type ConcatStreamOptions = { - childStreamMode: StrictExtract; -}; - -export class ConcatWrapperStream { - #ffmpegSettings: FfmpegSettings; - - constructor( - private channel: Channel, - private concatOptions: ConcatStreamOptions & Partial, - settings: SettingsDB = getSettings(), - ) { - this.#ffmpegSettings = settings.ffmpegSettings(); - } - - createSession(): Promise { - const concatUrl = makeLocalUrl( - `/stream/channels/${this.channel.uuid}.m3u8`, - { - mode: this.concatOptions.childStreamMode, - }, - ); - - const ffmpeg = this.#ffmpegSettings.useNewFfmpegPipeline - ? new FfmpegStreamFactory(this.#ffmpegSettings, this.channel) - : new FFMPEG(this.#ffmpegSettings, this.channel); - - return ffmpeg.createWrapperConcatSession( - concatUrl, - this.concatOptions.childStreamMode, - ); - } -} diff --git a/server/src/stream/OfflinePlayer.ts b/server/src/stream/OfflinePlayer.ts index 47f8d5c7e..da69c1ee1 100644 --- a/server/src/stream/OfflinePlayer.ts +++ b/server/src/stream/OfflinePlayer.ts @@ -1,8 +1,8 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; -import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.ts'; +import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js'; import { OutputFormat } from '@/ffmpeg/builder/constants.ts'; -import { FFMPEG, StreamOptions } from '@/ffmpeg/ffmpeg.js'; +import { StreamOptions } from '@/ffmpeg/ffmpeg.js'; import { Result } from '@/types/result.js'; import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; import { makeLocalUrl } from '@/util/serverUtil.js'; @@ -46,16 +46,10 @@ export class OfflineProgramStream extends ProgramStream { opts?: Partial, ): Promise> { try { - const ffmpeg = this.context.useNewPipeline - ? new FfmpegStreamFactory( - this.settingsDB.ffmpegSettings(), - this.context.channel, - ) - : new FFMPEG( - this.settingsDB.ffmpegSettings(), - this.context.channel, - this.context.audioOnly, - ); + const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( + this.settingsDB.ffmpegSettings(), + this.context.channel, + ); const lineupItem = this.context.lineupItem; let duration = dayjs.duration(lineupItem.streamDuration ?? 0); const start = dayjs.duration(lineupItem.start ?? 0); diff --git a/server/src/stream/ProgramStream.ts b/server/src/stream/ProgramStream.ts index 9b77dfab7..23448e47d 100644 --- a/server/src/stream/ProgramStream.ts +++ b/server/src/stream/ProgramStream.ts @@ -1,7 +1,8 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; +import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js'; import { OutputFormat } from '@/ffmpeg/builder/constants.ts'; -import { FFMPEG, StreamOptions } from '@/ffmpeg/ffmpeg.js'; +import { StreamOptions } from '@/ffmpeg/ffmpeg.js'; import { serverContext } from '@/serverContext.js'; import { TypedEventEmitter } from '@/types/eventEmitter.js'; import { Result } from '@/types/result.js'; @@ -133,10 +134,9 @@ export abstract class ProgramStream extends (events.EventEmitter as new () => Ty } private getErrorStream(context: PlayerContext) { - const ffmpeg = new FFMPEG( + const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( this.settingsDB.ffmpegSettings(), context.channel, - context.audioOnly, ); const duration = dayjs.duration( @@ -148,6 +148,7 @@ export abstract class ProgramStream extends (events.EventEmitter as new () => Ty 'Check server logs for details', duration, this.outputFormat, + true, ); } diff --git a/server/src/stream/ProgramStreamFactory.ts b/server/src/stream/ProgramStreamFactory.ts index 3a31bf8dd..3918c479f 100644 --- a/server/src/stream/ProgramStreamFactory.ts +++ b/server/src/stream/ProgramStreamFactory.ts @@ -1,6 +1,6 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { MediaSourceType } from '@/db/schema/MediaSource.ts'; -import { NutOutputFormat, OutputFormat } from '@/ffmpeg/builder/constants.ts'; +import { OutputFormat } from '@/ffmpeg/builder/constants.ts'; import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; import { OfflineProgramStream } from './OfflinePlayer.js'; import { PlayerContext } from './PlayerStreamContext.js'; @@ -18,7 +18,7 @@ export class ProgramStreamFactory { static create( context: PlayerContext, - outputFormat: OutputFormat = NutOutputFormat, + outputFormat: OutputFormat, settingsDB: SettingsDB = getSettings(), ): ProgramStream { let streamType: string; diff --git a/server/src/stream/VideoStream.ts b/server/src/stream/VideoStream.ts index ff400082b..44bbb6f63 100644 --- a/server/src/stream/VideoStream.ts +++ b/server/src/stream/VideoStream.ts @@ -1,3 +1,4 @@ +import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.ts'; import { getServerContext, serverContext } from '@/serverContext.ts'; import { Result } from '@/types/result.ts'; import { fileExists } from '@/util/fsUtil.js'; @@ -114,6 +115,7 @@ export class VideoStream { startTime: startTimestamp, sessionToken, }); + programStreamResult = lineupItemResult.map((result) => { const playerContext = new PlayerContext( result.lineupItem, @@ -122,7 +124,16 @@ export class VideoStream { result.lineupItem.type === 'loading', true, ); - return ProgramStreamFactory.create(playerContext); + const programStream = ProgramStreamFactory.create( + playerContext, + MpegTsOutputFormat, + ); + programStream.on('error', () => { + this.logger.error( + `Unrecoverable error in underlying FFMPEG process`, + ); + }); + return programStream; }); break; } diff --git a/server/src/stream/hls/HlsSlowerSession.ts b/server/src/stream/hls/HlsSlowerSession.ts index c87384c56..077bd1568 100644 --- a/server/src/stream/hls/HlsSlowerSession.ts +++ b/server/src/stream/hls/HlsSlowerSession.ts @@ -1,7 +1,8 @@ import { getSettings } from '@/db/SettingsDB.ts'; import { Channel } from '@/db/schema/Channel.ts'; +import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.ts'; -import { FFMPEG, defaultHlsOptions } from '@/ffmpeg/ffmpeg.ts'; +import { defaultHlsOptions } from '@/ffmpeg/ffmpeg.ts'; import { serverContext } from '@/serverContext.ts'; import { ProgramStream } from '@/stream/ProgramStream.ts'; import { ProgramStreamFactory } from '@/stream/ProgramStreamFactory.ts'; @@ -114,6 +115,14 @@ export class HlsSlowerSession extends BaseHlsSession { } } + programStream.on('error', () => { + this.state = 'error'; + this.error = new Error( + `Unrecoverable error in underlying FFMPEG process`, + ); + this.emit('error', this.error); + }); + transcodeSessionResult.forEach((transcodeSession) => { this.transcodedUntil = this.transcodedUntil.add( transcodeSession.streamDuration, @@ -138,9 +147,15 @@ export class HlsSlowerSession extends BaseHlsSession { mode: this.sessionType, }); - const ffmpeg = new FFMPEG(this.settingsDB.ffmpegSettings(), this.channel); + const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( + this.settingsDB.ffmpegSettings(), + this.channel, + ); this.#concatSession = await ffmpeg.createConcatSession(streamUrl, { + mode: 'hls_slower_concat', + logOutput: false, + numThreads: this.settingsDB.ffmpegSettings().numThreads, outputFormat: HlsOutputFormat({ ...defaultHlsOptions, streamBasePath: `stream_${this.channel.uuid}`, diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index 3624db024..292bbe1a4 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -3,9 +3,10 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { isContentBackedLineupIteam } from '@/db/derived_types/StreamLineup.ts'; import { MediaSourceDB } from '@/db/mediaSourceDB.ts'; import { MediaSourceType } from '@/db/schema/MediaSource.ts'; +import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js'; import { OutputFormat } from '@/ffmpeg/builder/constants.ts'; -import { FFMPEG } from '@/ffmpeg/ffmpeg.js'; +import { IFFMPEG } from '@/ffmpeg/ffmpegBase.ts'; import { PlayerContext } from '@/stream/PlayerStreamContext.js'; import { ProgramStream } from '@/stream/ProgramStream.js'; import { UpdateJellyfinPlayStatusScheduledTask } from '@/tasks/jellyfin/UpdateJellyfinPlayStatusTask.js'; @@ -22,7 +23,7 @@ export class JellyfinProgramStream extends ProgramStream { caller: import.meta, className: JellyfinProgramStream.name, }); - private ffmpeg: Nullable = null; + private ffmpeg: Nullable = null; private killed: boolean = false; private updatePlayStatusTask: Maybe; @@ -75,7 +76,10 @@ export class JellyfinProgramStream extends ProgramStream { ); const watermark = await this.getWatermark(); - this.ffmpeg = new FFMPEG(ffmpegSettings, channel, this.context.audioOnly); // Set the transcoder options + this.ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( + ffmpegSettings, + channel, + ); const stream = await jellyfinStreamDetails.getStream(lineupItem); if (isNull(stream)) { diff --git a/server/src/stream/plex/PlexProgramStream.ts b/server/src/stream/plex/PlexProgramStream.ts index e45277d3a..36314ec95 100644 --- a/server/src/stream/plex/PlexProgramStream.ts +++ b/server/src/stream/plex/PlexProgramStream.ts @@ -3,10 +3,10 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { isContentBackedLineupIteam } from '@/db/derived_types/StreamLineup.ts'; import { MediaSourceDB } from '@/db/mediaSourceDB.ts'; import { MediaSourceType } from '@/db/schema/MediaSource.ts'; -import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.ts'; +import { FFmpegFactory } from '@/ffmpeg/FFmpegFactory.ts'; import { FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js'; import { OutputFormat } from '@/ffmpeg/builder/constants.ts'; -import { FFMPEG, StreamOptions } from '@/ffmpeg/ffmpeg.js'; +import { StreamOptions } from '@/ffmpeg/ffmpeg.js'; import { GlobalScheduler } from '@/services/Scheduler.ts'; import { PlayerContext } from '@/stream/PlayerStreamContext.js'; import { ProgramStream } from '@/stream/ProgramStream.js'; @@ -78,9 +78,10 @@ export class PlexProgramStream extends ProgramStream { const plexStreamDetails = new PlexStreamDetails(server); const watermark = await this.getWatermark(); - const ffmpeg = this.context.useNewPipeline - ? new FfmpegStreamFactory(ffmpegSettings, channel) - : new FFMPEG(ffmpegSettings, channel, this.context.audioOnly); // Set the transcoder options + const ffmpeg = FFmpegFactory.getFFmpegPipelineBuilder( + ffmpegSettings, + channel, + ); const stream = await plexStreamDetails.getStream(lineupItem); if (isNull(stream)) {