From 37b28618fc3d2c02a9d2ba03f2c4683504ec1e4c Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 7 Jan 2025 12:30:53 -0500 Subject: [PATCH 01/24] feat: start work on enabling subs --- .../builder/FfmpegCommandGenerator.test.ts | 20 +++++++- .../ffmpeg/builder/FfmpegCommandGenerator.ts | 11 +++++ server/src/ffmpeg/builder/MediaStream.ts | 18 +++++++ .../src/ffmpeg/builder/encoder/BaseEncoder.ts | 6 +++ .../src/ffmpeg/builder/input/InputSource.ts | 2 +- .../builder/input/SubtitleInputSource.ts | 20 ++++++++ .../builder/options/SubtitleOutputOptions.ts | 4 ++ .../builder/pipeline/BasePipelineBuilder.ts | 40 ++++++++++++++- .../src/ffmpeg/builder/pipeline/Pipeline.ts | 1 + .../pipeline/PipelineBuilderFactory.ts | 14 ++++++ .../ffmpeg/builder/pipeline/PipelineInputs.ts | 2 + .../hardware/NvidiaPipelineBuilder.ts | 3 ++ .../pipeline/hardware/VaapiPipelineBuilder.ts | 3 ++ .../hardware/VideoToolboxPipelineBuilder.ts | 3 ++ .../src/ffmpeg/builder/state/SubtitleState.ts | 49 +++++++++++++++++++ server/src/ffmpeg/builder/types.ts | 8 ++- 16 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 server/src/ffmpeg/builder/input/SubtitleInputSource.ts create mode 100644 server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts create mode 100644 server/src/ffmpeg/builder/state/SubtitleState.ts diff --git a/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts b/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts index 05ed93b0a..391caac12 100644 --- a/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts +++ b/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts @@ -2,7 +2,12 @@ import { bootstrapTunarr } from '@/bootstrap.ts'; import { setGlobalOptions } from '@/globals.ts'; import tmp from 'tmp'; import { FfmpegCommandGenerator } from './FfmpegCommandGenerator.ts'; -import { AudioStream, StillImageStream, VideoStream } from './MediaStream.ts'; +import { + AudioStream, + StillImageStream, + SubtitleStream, + VideoStream, +} from './MediaStream.ts'; import { VideoFormats } from './constants.ts'; import { PixelFormat, @@ -10,6 +15,7 @@ import { PixelFormatYuv420P10Le, } from './format/PixelFormat.ts'; import { AudioInputSource } from './input/AudioInputSource.ts'; +import { SubtitleInputSource } from './input/SubtitleInputSource.ts'; import { VideoInputSource } from './input/VideoInputSource.ts'; import { WatermarkInputSource } from './input/WatermarkInputSource.ts'; import { PipelineBuilderFactory } from './pipeline/PipelineBuilderFactory.ts'; @@ -71,6 +77,10 @@ describe('FfmpegCommandGenerator', () => { audioDuration: 11_000, }); + const subtitleState = SubtitleState.create({ + subtitleEncoder: 'srt', + }); + const target = FrameSize.withDimensions(1280, 720); const desiredState = new FrameState({ @@ -98,6 +108,12 @@ describe('FfmpegCommandGenerator', () => { audioState, ); + const subtitleInputFile = new SubtitleInputSource( + videoInputFile.path, + [SubtitleStream.create({ encoder: 'srt' })], + subtitleState, + ); + const watermarkInputFile = new WatermarkInputSource( 'http://localhost:8000/images/tunarr.png', StillImageStream.create({ @@ -120,6 +136,7 @@ describe('FfmpegCommandGenerator', () => { .setHardwareAccelerationMode('vaapi') .setVideoInputSource(videoInputFile) .setAudioInputSource(audioInputFile) + .setSubtitleInputSource(subtitleInputFile) .setWatermarkInputSource(watermarkInputFile) .build(); @@ -134,6 +151,7 @@ describe('FfmpegCommandGenerator', () => { const result = generator.generateArgs( videoInputFile, audioInputFile, + subtitleInputFile, watermarkInputFile, steps, ); diff --git a/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts b/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts index 05c0e9c0a..9920d2151 100644 --- a/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts +++ b/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts @@ -6,6 +6,7 @@ import { BaseEncoder } from './encoder/BaseEncoder.ts'; import { ComplexFilter } from './filter/ComplexFilter.ts'; import { AudioInputSource } from './input/AudioInputSource.ts'; import { ConcatInputSource } from './input/ConcatInputSource.ts'; +import { SubtitleInputSource } from './input/SubtitleInputSource.ts'; import { VideoInputSource } from './input/VideoInputSource.ts'; import { WatermarkInputSource } from './input/WatermarkInputSource.ts'; import { @@ -32,6 +33,7 @@ export class FfmpegCommandGenerator { generateArgs( videoInputSource: Nullable, audioInputSource: Nullable, + subtitleInputSource: Nullable, watermarkInputSource: Nullable, concatInputSource: Nullable, steps: PipelineStep[], @@ -60,6 +62,15 @@ export class FfmpegCommandGenerator { ); } + if (subtitleInputSource && !includedPaths.has(subtitleInputSource.path)) { + includedPaths.add(subtitleInputSource.path); + args.push( + ...subtitleInputSource.getInputOptions(), + '-i', + subtitleInputSource.path, + ); + } + if (watermarkInputSource && !includedPaths.has(watermarkInputSource.path)) { includedPaths.add(watermarkInputSource.path); args.push( diff --git a/server/src/ffmpeg/builder/MediaStream.ts b/server/src/ffmpeg/builder/MediaStream.ts index 30c56ec1c..872441852 100644 --- a/server/src/ffmpeg/builder/MediaStream.ts +++ b/server/src/ffmpeg/builder/MediaStream.ts @@ -22,6 +22,7 @@ type MediaStreamFields = Omit< // semantics with class construction, but still enabling us // to have hierarchies, methods, etc. type AudioStreamFields = MediaStreamFields; +type SubtitleStreamFields = MediaStreamFields; type VideoStreamFields = Omit, 'isAnamorphic'>; export class AudioStream implements MediaStream { @@ -41,6 +42,23 @@ export class AudioStream implements MediaStream { } } +export class SubtitleStream implements MediaStream { + readonly kind: StreamKind = 'subtitle'; + index: number; + codec: string; + language?: string; + + private constructor(fields: SubtitleStreamFields) { + this.index = fields.index; + this.codec = fields.codec; + this.language = fields.language; + } + + static create(fields: SubtitleStreamFields) { + return new SubtitleStream(fields); + } +} + export type VideoInputKind = 'video' | 'stillimage' | 'filter'; export class VideoStream implements MediaStream { diff --git a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts index 3a745e1d0..5126281e4 100644 --- a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts +++ b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts @@ -46,6 +46,12 @@ export class AudioEncoder extends BaseEncoder { } } +export class SubtitleEncoder extends BaseEncoder { + constructor(encoder: string) { + super(encoder, 'subtitle'); + } +} + export abstract class VideoEncoder extends BaseEncoder { protected abstract videoFormat: string; diff --git a/server/src/ffmpeg/builder/input/InputSource.ts b/server/src/ffmpeg/builder/input/InputSource.ts index 3a666f160..aa5a620a4 100644 --- a/server/src/ffmpeg/builder/input/InputSource.ts +++ b/server/src/ffmpeg/builder/input/InputSource.ts @@ -10,7 +10,7 @@ import { } from '../../../stream/types.ts'; import { VideoInputSource } from './VideoInputSource.ts'; -export type InputSourceType = 'video' | 'audio'; +export type InputSourceType = 'video' | 'audio' | 'subtitle'; export type InputSourceProtocol = 'file' | 'http' | 'filter'; export type InputSourceContinuity = 'discrete' | 'infinite'; diff --git a/server/src/ffmpeg/builder/input/SubtitleInputSource.ts b/server/src/ffmpeg/builder/input/SubtitleInputSource.ts new file mode 100644 index 000000000..516bbef28 --- /dev/null +++ b/server/src/ffmpeg/builder/input/SubtitleInputSource.ts @@ -0,0 +1,20 @@ +import { SubtitleStream } from '@/ffmpeg/builder/MediaStream.ts'; +import { + InputSource, + InputSourceContinuity, + StreamSource, +} from './InputSource.ts'; + +export class SubtitleInputSource< + StreamType extends SubtitleStream = SubtitleStream, +> extends InputSource { + readonly type = 'subtitle'; + + constructor( + source: StreamSource, + public streams: StreamType[], + continuity: InputSourceContinuity = 'discrete', + ) { + super(source, continuity); + } +} diff --git a/server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts b/server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts new file mode 100644 index 000000000..2f189b57a --- /dev/null +++ b/server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts @@ -0,0 +1,4 @@ +import { makeConstantOutputOption } from './OutputOption.ts'; + +export const SubtitleFormatOutputOption = (subtitleFormat: string) => + makeConstantOutputOption([`${subtitleFormat}`]); diff --git a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts index 2b92eb138..a877040a0 100644 --- a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts @@ -1,9 +1,14 @@ -import { AudioStream, VideoStream } from '@/ffmpeg/builder/MediaStream.ts'; +import { + AudioStream, + SubtitleStream, + VideoStream, +} from '@/ffmpeg/builder/MediaStream.ts'; import { FfmpegCapabilities } from '@/ffmpeg/builder/capabilities/FfmpegCapabilities.ts'; import { Decoder } from '@/ffmpeg/builder/decoder/Decoder.ts'; import { DecoderFactory } from '@/ffmpeg/builder/decoder/DecoderFactory.ts'; import { AudioEncoder, + SubtitleEncoder, VideoEncoder, } from '@/ffmpeg/builder/encoder/BaseEncoder.ts'; import { Encoder } from '@/ffmpeg/builder/encoder/Encoder.ts'; @@ -31,6 +36,7 @@ import { UserAgentInputOption } from '@/ffmpeg/builder/options/input/UserAgentIn import { AudioState } from '@/ffmpeg/builder/state/AudioState.ts'; import { FfmpegState } from '@/ffmpeg/builder/state/FfmpegState.ts'; import { FrameState } from '@/ffmpeg/builder/state/FrameState.ts'; +import { SubtitleState } from '@/ffmpeg/builder/state/SubtitleState.ts'; import { FrameDataLocation, HardwareAccelerationMode, @@ -101,6 +107,7 @@ import { } from '../options/OutputOption.ts'; import { Pipeline } from './Pipeline.ts'; import { PipelineBuilder } from './PipelineBuilder.ts'; +import { SubtitleInputSource } from '../input/SubtitleInputSource.ts'; // Args passed to each setter -- we use an object here so we // 1. can deconstruct args in each implementor to use only what we need @@ -124,9 +131,11 @@ export type PipelineAudioFunctionArgs = { export type PipelineBuilderContext = { videoStream?: VideoStream; audioStream?: AudioStream; + subtitleStream?: SubtitleStream; ffmpegState: FfmpegState; desiredState: FrameState; desiredAudioState?: AudioState; + desiredSubtitleState?: SubtitleState; pipelineSteps: PipelineStep[]; filterChain: FilterChain; decoder: Nullable; @@ -146,6 +155,10 @@ export type PipelineBuilderContextWithAudio = MarkRequired< PipelineBuilderContext, 'audioStream' | 'desiredAudioState' >; +export type PipelineBuilderContextWithSubtitles = MarkRequired< + PipelineBuilderContext, + 'subtitleStream' | 'desiredSubtitleState' +>; export function isVideoPipelineContext( context: PipelineBuilderContext, @@ -161,6 +174,15 @@ export function isAudioPipelineContext( ); } +export function isSubtitlePipelineContext( + context: PipelineBuilderContext, +): context is PipelineBuilderContextWithSubtitles { + return ( + !isUndefined(context.subtitleStream) && + !isUndefined(context.desiredSubtitleState) + ); +} + export abstract class BasePipelineBuilder implements PipelineBuilder { protected logger: Logger = LoggerFactory.child({ caller: import.meta, @@ -172,6 +194,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { constructor( protected nullableVideoInputSource: Nullable, private audioInputSource: Nullable, + private subtitleInputSource: Nullable, protected watermarkInputSource: Nullable, protected concatInputSource: Nullable, protected ffmpegCapabilities: FfmpegCapabilities, @@ -230,6 +253,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(pipelineSteps, { videoInput: null, audioInput: null, + subtitleInput: null, concatInput: input, watermarkInput: null, }); @@ -270,6 +294,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(pipelineSteps, { videoInput: null, audioInput: null, + subtitleInput: null, concatInput: input, watermarkInput: null, }); @@ -279,6 +304,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { this.context = { videoStream: first(this.videoInputSource.streams), audioStream: first(this.audioInputSource?.streams), + subtitleStream: first(this.subtitleInputSource?.streams), ffmpegState, desiredState, desiredAudioState: this.audioInputSource?.desiredState, @@ -420,6 +446,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { new ComplexFilter( this.videoInputSource, this.audioInputSource, + this.subtitleInputSource, this.watermarkInputSource, this.context.filterChain, ), @@ -432,6 +459,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(this.pipelineSteps, { videoInput: this.videoInputSource, audioInput: this.audioInputSource, + subtitleInput: this.subtitleInputSource, watermarkInput: this.watermarkInputSource, concatInput: this.concatInputSource, }); @@ -536,6 +564,16 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { } } + protected buildSubtitlePipeline() { + if (!isSubtitlePipelineContext(this.context)) { + return; + } + const encoder = new SubtitleEncoder( + this.context.desiredSubtitleState.subtitleEncoder, + ); + this.pipelineSteps.push(encoder); + } + protected abstract setupVideoFilters(): void; protected setupEncoder(currentState: FrameState): Nullable { diff --git a/server/src/ffmpeg/builder/pipeline/Pipeline.ts b/server/src/ffmpeg/builder/pipeline/Pipeline.ts index 258c930d2..49cf22468 100644 --- a/server/src/ffmpeg/builder/pipeline/Pipeline.ts +++ b/server/src/ffmpeg/builder/pipeline/Pipeline.ts @@ -18,6 +18,7 @@ export class Pipeline { return this.#commandGenerator.generateArgs( this.inputs.videoInput, this.inputs.audioInput, + this.inputs.subtitleInput, this.inputs.watermarkInput, this.inputs.concatInput, this.steps, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts index 2a44aa586..8b23178d4 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts @@ -16,6 +16,7 @@ import { QsvPipelineBuilder } from './hardware/QsvPipelineBuilder.ts'; import { VaapiPipelineBuilder } from './hardware/VaapiPipelineBuilder.ts'; import { VideoToolboxPipelineBuilder } from './hardware/VideoToolboxPipelineBuilder.ts'; import { SoftwarePipelineBuilder } from './software/SoftwarePipelineBuilder.ts'; +import { SubtitleInputSource } from '../input/SubtitleInputSource.ts'; export class PipelineBuilderFactory { constructor(private settingsDB: SettingsDB = getSettings()) {} @@ -31,6 +32,7 @@ export class PipelineBuilderFactory { class PipelineBuilderFactory$Builder { private videoInputSource: Nullable = null; private audioInputSource: Nullable = null; + private subtitleInputSource: Nullable = null; private concatInputSource: Nullable = null; private watermarkInputSource: Nullable = null; private hardwareAccelerationMode: HardwareAccelerationMode = 'none'; @@ -54,6 +56,13 @@ class PipelineBuilderFactory$Builder { return this; } + setSubtitleInputSource( + subtitleInputSource: Nullable, + ): PipelineBuilderFactory$Builder { + this.subtitleInputSource = subtitleInputSource; + return this; + } + setConcatInputSource(concatInputSource: ConcatInputSource): this { this.concatInputSource = concatInputSource; return this; @@ -93,6 +102,7 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, + this.subtitleInputSource, this.concatInputSource, this.watermarkInputSource, ); @@ -100,6 +110,7 @@ class PipelineBuilderFactory$Builder { return new QsvPipelineBuilder( this.videoInputSource, this.audioInputSource, + this.subtitleInputSource, this.watermarkInputSource, this.concatInputSource, binaryCapabilities, @@ -110,6 +121,7 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, + this.subtitleInputSource, this.watermarkInputSource, this.concatInputSource, ); @@ -119,6 +131,7 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, + this.subtitleInputSource, this.concatInputSource, this.watermarkInputSource, ); @@ -126,6 +139,7 @@ class PipelineBuilderFactory$Builder { return new SoftwarePipelineBuilder( this.videoInputSource, this.audioInputSource, + this.subtitleInputSource, this.watermarkInputSource, this.concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts index e458d289e..7312916fe 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts @@ -1,5 +1,6 @@ import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; +import { SubtitleInputSource } from '../input/SubtitleInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { Nullable } from '@/types/util.ts'; @@ -7,6 +8,7 @@ import { Nullable } from '@/types/util.ts'; export type PipelineInputs = { videoInput: Nullable; audioInput: Nullable; + subtitleInput: Nullable; watermarkInput: Nullable; concatInput: Nullable; }; diff --git a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts index 0e08cffe9..7ae3abb54 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts @@ -21,6 +21,7 @@ import { YadifCudaFilter } from '@/ffmpeg/builder/filter/nvidia/YadifCudaFilter. import { OverlayWatermarkFilter } from '@/ffmpeg/builder/filter/watermark/OverlayWatermarkFilter.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; +import { SubtitleInputSource } from '../../input/SubtitleInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { PixelFormatOutputOption } from '@/ffmpeg/builder/options/OutputOption.ts'; @@ -53,12 +54,14 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, + subtitleInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, ) { super( videoInputFile, audioInputFile, + subtitleInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts index eee47f79b..7e458e542 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts @@ -18,6 +18,7 @@ import { WatermarkOpacityFilter } from '@/ffmpeg/builder/filter/watermark/Waterm import { WatermarkScaleFilter } from '@/ffmpeg/builder/filter/watermark/WatermarkScaleFilter.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; +import { SubtitleInputSource } from '../../input/SubtitleInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { VaapiDriverEnvironmentVariable } from '@/ffmpeg/builder/options/EnvironmentVariables.ts'; @@ -59,12 +60,14 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, + subtitleInputFile: Nullable, watermarkInputSource: Nullable, concatInputSource: Nullable, ) { super( videoInputFile, audioInputFile, + subtitleInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts index 98d3e35cd..2b1a903f3 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts @@ -7,6 +7,7 @@ import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.ts'; import { VideoToolboxHardwareAccelerationOption } from '@/ffmpeg/builder/filter/videotoolbox/VideoToolboxHardwareAccelerationOption.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; +import { SubtitleInputSource } from '../../input/SubtitleInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { PixelFormatOutputOption } from '@/ffmpeg/builder/options/OutputOption.ts'; @@ -27,12 +28,14 @@ export class VideoToolboxPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, + subtitleInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, ) { super( videoInputFile, audioInputFile, + subtitleInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/state/SubtitleState.ts b/server/src/ffmpeg/builder/state/SubtitleState.ts new file mode 100644 index 000000000..25d63d876 --- /dev/null +++ b/server/src/ffmpeg/builder/state/SubtitleState.ts @@ -0,0 +1,49 @@ +import { ExcludeByValueType, Nullable } from '@/types/util.ts'; +import { isNil, omitBy } from 'lodash-es'; +import { AnyFunction } from 'ts-essentials'; + +export type SubtitleStateFields = ExcludeByValueType< + SubtitleState, + AnyFunction +>; + +const DefaultSubtitleState: SubtitleState = { + subtitleEncoder: 'srt', + subtitleLanguage: 'und', // 'und' for undefined + subtitleCodec: null, + subtitleFormat: null, + subtitleDelay: null, + subtitleDuration: null, + subtitleDefault: false, + subtitleForced: false, +}; + +export class SubtitleState { + subtitleEncoder: string; + subtitleLanguage: string; // Language code, e.g., 'en', 'es', 'fr' + subtitleCodec: Nullable; + subtitleFormat: Nullable; // Format like SRT, ASS, etc. + subtitleDelay: Nullable; // Delay in milliseconds + subtitleDuration: Nullable; // Duration in milliseconds + subtitleDefault: boolean; // Whether it's the default subtitle + subtitleForced: boolean; // Whether it's a forced subtitle + + private constructor(fields: Partial = {}) { + const merged: SubtitleStateFields = { + ...DefaultSubtitleState, + ...omitBy(fields, isNil), + }; + this.subtitleEncoder = merged.subtitleEncoder; + this.subtitleLanguage = merged.subtitleLanguage; + this.subtitleCodec = merged.subtitleCodec; + this.subtitleFormat = merged.subtitleFormat; + this.subtitleDelay = merged.subtitleDelay; + this.subtitleDuration = merged.subtitleDuration; + this.subtitleDefault = merged.subtitleDefault; + this.subtitleForced = merged.subtitleForced; + } + + static create(fields: Partial = {}) { + return new SubtitleState(fields); + } +} diff --git a/server/src/ffmpeg/builder/types.ts b/server/src/ffmpeg/builder/types.ts index 183f9399b..6e3e894c7 100644 --- a/server/src/ffmpeg/builder/types.ts +++ b/server/src/ffmpeg/builder/types.ts @@ -28,7 +28,13 @@ export type HardwareAccelerationMode = TupleToUnion< typeof HardwareAccelerationModes >; -export const StreamKinds = ['audio', 'video', 'all', 'stillimage'] as const; +export const StreamKinds = [ + 'audio', + 'video', + 'all', + 'stillimage', + 'subtitle', +] as const; export type StreamKind = TupleToUnion; From b7caea4273c73c35181e1765cfcbfa7c88d58004 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 7 Jan 2025 17:53:24 -0500 Subject: [PATCH 02/24] feat: standardize around plural; update schema and add migration --- server/src/api/streamApi.ts | 6 +- server/src/db/ChannelDB.ts | 33 +++- server/src/db/converters/channelConverters.ts | 3 + server/src/db/derived_types/Lineup.ts | 18 ++ server/src/db/schema/TranscodeConfig.ts | 19 ++ .../ffmpeg/FfmpegPlaybackParamsCalculator.ts | 8 + server/src/ffmpeg/FfmpegStreamFactory.ts | 40 ++++- .../builder/FfmpegCommandGenerator.test.ts | 17 +- .../ffmpeg/builder/FfmpegCommandGenerator.ts | 12 +- server/src/ffmpeg/builder/MediaStream.ts | 10 +- server/src/ffmpeg/builder/constants.ts | 5 + ...InputSource.ts => SubtitlesInputSource.ts} | 6 +- .../builder/pipeline/BasePipelineBuilder.ts | 14 +- .../pipeline/PipelineBuilderFactory.ts | 20 +-- .../ffmpeg/builder/pipeline/PipelineInputs.ts | 2 +- .../hardware/NvidiaPipelineBuilder.ts | 6 +- .../pipeline/hardware/VaapiPipelineBuilder.ts | 6 +- .../hardware/VideoToolboxPipelineBuilder.ts | 6 +- .../src/ffmpeg/builder/state/SubtitleState.ts | 49 ------ .../ffmpeg/builder/state/SubtitlesState.ts | 49 ++++++ .../src/migration/DirectMigrationProvider.ts | 2 + .../db/Migration1736426451_AddSubtitles.ts | 13 ++ server/src/serverContext.ts | 2 + server/src/services/SubtitlesService.ts | 165 ++++++++++++++++++ server/src/stream/types.ts | 14 ++ types/src/schemas/channelSchema.ts | 3 + .../ChannelPropertiesEditor.tsx | 26 +++ .../channel_config/EditChannelForm.tsx | 5 +- 28 files changed, 456 insertions(+), 103 deletions(-) rename server/src/ffmpeg/builder/input/{SubtitleInputSource.ts => SubtitlesInputSource.ts} (67%) delete mode 100644 server/src/ffmpeg/builder/state/SubtitleState.ts create mode 100644 server/src/ffmpeg/builder/state/SubtitlesState.ts create mode 100644 server/src/migration/db/Migration1736426451_AddSubtitles.ts create mode 100644 server/src/services/SubtitlesService.ts diff --git a/server/src/api/streamApi.ts b/server/src/api/streamApi.ts index 9e8f30858..2a91c387e 100644 --- a/server/src/api/streamApi.ts +++ b/server/src/api/streamApi.ts @@ -19,7 +19,7 @@ import { PassThrough } from 'stream'; import { v4 } from 'uuid'; import z from 'zod'; -// eslint-disable-next-line @typescript-eslint/require-await + export const streamApi: RouterPluginAsyncCallback = async (fastify) => { const logger = LoggerFactory.child({ caller: import.meta, @@ -139,6 +139,10 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => { const session = sessionResult.get(); + if (lineup.subtitlesConfig) { + await req.serverCtx.subtitlesService.resumeChannel(channel.uuid); + } + if (lineup.onDemandConfig) { await req.serverCtx.onDemandChannelService.resumeChannel(channel.uuid); } diff --git a/server/src/db/ChannelDB.ts b/server/src/db/ChannelDB.ts index 2c34d9297..5aabb20df 100644 --- a/server/src/db/ChannelDB.ts +++ b/server/src/db/ChannelDB.ts @@ -219,7 +219,11 @@ type UpdateChannelLineupRequest = MarkOptional< | 'schedulingOperations' | 'pendingPrograms' >, - 'version' | 'onDemandConfig' | 'items' | 'startTimeOffsets' + | 'version' + | 'onDemandConfig' + | 'subtitlesConfig' + | 'items' + | 'startTimeOffsets' >; export class ChannelDB { private logger = LoggerFactory.child({ @@ -391,6 +395,15 @@ export class ChannelDB { await this.createLineup(channel.uuid); + if (isDefined(createReq.subtitles) && createReq.subtitles.enabled) { + const db = await this.getFileDb(channel.uuid); + await db.update((lineup) => { + lineup.subtitlesConfig = { + state: 'disabled', + }; + }); + } + if (isDefined(createReq.onDemand) && createReq.onDemand.enabled) { const db = await this.getFileDb(channel.uuid); await db.update((lineup) => { @@ -471,6 +484,19 @@ export class ChannelDB { } }); + if (isDefined(updateReq.subtitles)) { + const db = await this.getFileDb(id); + await db.update((lineup) => { + if (updateReq.subtitles?.enabled ?? false) { + lineup.subtitlesConfig = { + state: 'disabled', + }; + } else { + delete lineup['subtitlesConfig']; + } + }); + } + if (isDefined(updateReq.onDemand)) { const db = await this.getFileDb(id); await db.update((lineup) => { @@ -741,6 +767,11 @@ export class ChannelDB { cursor: 0, } : undefined, + subtitlesConfig: isDefined(lineup.subtitlesConfig) + ? { + ...lineup.subtitlesConfig, + } + : undefined, }), ); diff --git a/server/src/db/converters/channelConverters.ts b/server/src/db/converters/channelConverters.ts index 0ff675b1b..9f7572445 100644 --- a/server/src/db/converters/channelConverters.ts +++ b/server/src/db/converters/channelConverters.ts @@ -33,6 +33,9 @@ export const dbChannelToApiChannel = ({ transcoding: nilToUndefined(channel.transcoding), duration: channel.duration, stealth: channel.stealth === 1, + subtitles: { + enabled: isDefined(lineup.subtitlesConfig), + }, onDemand: { enabled: isDefined(lineup.onDemandConfig), }, diff --git a/server/src/db/derived_types/Lineup.ts b/server/src/db/derived_types/Lineup.ts index ad38941e5..ea5365916 100644 --- a/server/src/db/derived_types/Lineup.ts +++ b/server/src/db/derived_types/Lineup.ts @@ -79,6 +79,20 @@ export const OnDemandChannelConfigSchema = z.object({ export type OnDemandChannelConfig = z.infer; +export const SubtitlesConfigSchema = z.object({ + enabled: z + .union([z.literal('disabled'), z.literal('enabled')]) + .default('disabled') + .catch('disabled'), + state: z + .union([z.literal('paused'), z.literal('playing')]) + .default('paused') + .catch('paused'), + cursor: z.number().nonnegative().default(0).catch(0), +}); + +export type SubtitlesConfig = z.infer; + export const CurrentLineupSchemaVersion = 1; export const LineupSchema = z.object({ @@ -137,6 +151,10 @@ export const LineupSchema = z.object({ // OnDemand configuration for this channel. If empty, the channel // is not configured as on-demand. onDemandConfig: OnDemandChannelConfigSchema.optional(), + + // Subtitle configuration for this channel. If empty, the channel + // has subtitles turned off. + subtitlesConfig: SubtitlesConfigSchema.optional(), }); export type Lineup = z.infer; diff --git a/server/src/db/schema/TranscodeConfig.ts b/server/src/db/schema/TranscodeConfig.ts index 61594f8d2..0ef9d432e 100644 --- a/server/src/db/schema/TranscodeConfig.ts +++ b/server/src/db/schema/TranscodeConfig.ts @@ -70,6 +70,17 @@ export const TranscodeAudioOutputFormat = { Mp3: 'mp3' as const, } as const; +export const TranscodeSubtitlesOutputFormats = ['ass', 'srt'] as const; + +export type TranscodeSubtitlesOutputFormat = TupleToUnion< + typeof TranscodeSubtitlesOutputFormats +>; + +export const TranscodeSubtitlesOutputFormat = { + Ass: 'ass' as const, + Srt: 'srt' as const, +} as const; + export const ErrorScreenTypes = [ 'static', 'pic', @@ -99,6 +110,7 @@ export const TranscodeConfigColumns: (keyof TrannscodeConfigTable)[] = [ 'name', 'normalizeFrameRate', 'resolution', + 'subtitlesFormat', 'threadCount', 'uuid', 'vaapiDevice', @@ -139,6 +151,8 @@ export interface TrannscodeConfigTable extends WithUuid { audioSampleRate: number; audioVolumePercent: Generated; // Default 100 + subtitlesFormat: TranscodeSubtitlesOutputFormat; + normalizeFrameRate: Generated; // Boolean deinterlaceVideo: Generated; // Boolean disableChannelOverlay: Generated; // Boolean @@ -160,11 +174,15 @@ export const transcodeConfigFromLegacySettings = ( const audioSetting = TranscodeAudioOutputFormats.find( (fmt) => legacySettings.audioEncoder === fmt, ); + const subtitlesSetting = TranscodeSubtitlesOutputFormats.find( + (fmt) => legacySettings.subtitlesFormat === fmt, + ); const videoSetting = TranscodeVideoOutputFormats.find( (fmt) => legacySettings.videoFormat === fmt, ); const audioFormat = audioSetting ?? 'aac'; + const subtitlesFormat = subtitlesSetting ?? 'srt'; const videoFormat = videoSetting ?? 'h264'; return { @@ -182,6 +200,7 @@ export const transcodeConfigFromLegacySettings = ( ), threadCount: legacySettings.numThreads, uuid: v4(), + subtitlesFormat, videoBitRate: legacySettings.videoBitrate, videoBufferSize: legacySettings.videoBufferSize, videoFormat, diff --git a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts index a1ccac6d6..556adc682 100644 --- a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts +++ b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts @@ -1,6 +1,7 @@ import { TranscodeAudioOutputFormat, TranscodeConfig, + TranscodeSubtitlesOutputFormat, } from '@/db/schema/TranscodeConfig.ts'; import { ChannelStreamMode } from '@/db/schema/base.ts'; import { StreamDetails, VideoStreamDetails } from '@/stream/types.ts'; @@ -25,6 +26,7 @@ export class FfmpegPlaybackParamsCalculator { return { hwAccel: HardwareAccelerationMode.None, audioFormat: TranscodeAudioOutputFormat.Copy, + subtitlesFormat: TranscodeSubtitlesOutputFormat.Srt, videoFormat: 'copy', // Should be included in DB options deinterlace: false, } satisfies FfmpegPlaybackParams; @@ -38,6 +40,7 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, + subtitlesFormat: this.transcodeConfig.subtitlesFormat, videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, @@ -110,6 +113,7 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, + subtitlesFormat: this.transcodeConfig.subtitlesFormat, videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, @@ -159,6 +163,10 @@ export type FfmpegPlaybackParams = { audioChannels?: number; audioSampleRate?: number; audioDuration?: number; + + //subtitle details + subtitlesFormat: string; + subtitlesLanguage?: string; }; function needsToScale( diff --git a/server/src/ffmpeg/FfmpegStreamFactory.ts b/server/src/ffmpeg/FfmpegStreamFactory.ts index eecf047f7..90025fe8e 100644 --- a/server/src/ffmpeg/FfmpegStreamFactory.ts +++ b/server/src/ffmpeg/FfmpegStreamFactory.ts @@ -15,6 +15,7 @@ import { FfmpegTranscodeSession } from './FfmpegTrancodeSession.ts'; import { AudioStream, StillImageStream, + SubtitlesStream, VideoStream, } from './builder/MediaStream.ts'; import { @@ -36,12 +37,14 @@ import { } from './builder/input/AudioInputSource.ts'; import { ConcatInputSource } from './builder/input/ConcatInputSource.ts'; import { LavfiVideoInputSource } from './builder/input/LavfiVideoInputSource.ts'; +import { SubtitlesInputSource } from './builder/input/SubtitlesInputSource.ts'; import { VideoInputSource } from './builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from './builder/input/WatermarkInputSource.ts'; import { PipelineBuilderFactory } from './builder/pipeline/PipelineBuilderFactory.ts'; import { AudioState } from './builder/state/AudioState.ts'; import { FfmpegState } from './builder/state/FfmpegState.ts'; import { FrameState } from './builder/state/FrameState.ts'; +import { SubtitlesState } from './builder/state/SubtitlesState.ts'; import { FrameSize } from './builder/types.ts'; import { ConcatOptions, StreamSessionOptions } from './ffmpeg.ts'; import { HlsWrapperOptions, IFFMPEG } from './ffmpegBase.ts'; @@ -241,6 +244,7 @@ export class FfmpegStreamFactory extends IFFMPEG { ...pipeline.inputs, videoInput: null, audioInput: null, + subtitleInput: null, }); return new FfmpegTranscodeSession( @@ -367,6 +371,34 @@ export class FfmpegStreamFactory extends IFFMPEG { }); } + const subtitlesState = SubtitlesState.create({ + subtitlesEncoder: playbackParams.subtitlesFormat, + subtitlesLanguage: playbackParams.subtitlesLanguage, + }); + + let subtitlesInput: Nullable = null; + if (isDefined(streamDetails.subtitlesDetails)) { + const subtitlesStream = + find(streamDetails.subtitlesDetails, { selected: true }) ?? + find(streamDetails.subtitlesDetails, { default: true }) ?? + first(streamDetails.subtitlesDetails); + const subtitleStreamIndex = isNonEmptyString(subtitlesStream.index) + ? parseInt(subtitlesStream.index) + : 0; + + subtitlesInput = new SubtitlesInputSource( + streamSource, + [ + SubtitlesStream.create({ + index: isNaN(subtitleStreamIndex) ? 0 : subtitleStreamIndex, + codec: subtitlesStream.codec ?? 'unknown', + language: subtitlesStream.language ?? 'und', + }), + ], + subtitlesState, + ); + } + let watermarkSource: Nullable = null; if (streamMode !== ChannelStreamModes.HlsDirect && watermark?.enabled) { const watermarkUrl = watermark.url ?? makeLocalUrl('/images/tunarr.png'); @@ -388,6 +420,7 @@ export class FfmpegStreamFactory extends IFFMPEG { .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) .setVideoInputSource(videoInput) .setAudioInputSource(audioInput) + .setSubtitlesInputSource(subtitlesInput) .setWatermarkInputSource(watermarkSource) .build(); @@ -631,11 +664,14 @@ export class FfmpegStreamFactory extends IFFMPEG { break; } + let subtitlesInput: SubtitlesInputSource; + const builder = await new PipelineBuilderFactory() .builder(this.transcodeConfig) .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) .setVideoInputSource(offlineInput) .setAudioInputSource(audioInput) + .setSubtitlesInputSource(subtitlesInput) .build(); const pipeline = builder.build( @@ -686,8 +722,8 @@ export class FfmpegStreamFactory extends IFFMPEG { return isNonEmptyString(this.ffmpegSettings.vaapiDevice) ? this.ffmpegSettings.vaapiDevice : isLinux() - ? '/dev/dri/renderD128' - : undefined; + ? '/dev/dri/renderD128' + : undefined; } private getVaapiDriver() { diff --git a/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts b/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts index 391caac12..d5aba846b 100644 --- a/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts +++ b/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts @@ -5,7 +5,7 @@ import { FfmpegCommandGenerator } from './FfmpegCommandGenerator.ts'; import { AudioStream, StillImageStream, - SubtitleStream, + SubtitlesStream, VideoStream, } from './MediaStream.ts'; import { VideoFormats } from './constants.ts'; @@ -15,11 +15,12 @@ import { PixelFormatYuv420P10Le, } from './format/PixelFormat.ts'; import { AudioInputSource } from './input/AudioInputSource.ts'; -import { SubtitleInputSource } from './input/SubtitleInputSource.ts'; +import { SubtitlesInputSource } from './input/SubtitlesInputSource.ts'; import { VideoInputSource } from './input/VideoInputSource.ts'; import { WatermarkInputSource } from './input/WatermarkInputSource.ts'; import { PipelineBuilderFactory } from './pipeline/PipelineBuilderFactory.ts'; import { AudioState } from './state/AudioState.ts'; +import { SubtitlesState } from './state/SubtitlesState.ts'; import { FfmpegState } from './state/FfmpegState.ts'; import { FrameState } from './state/FrameState.ts'; import { FrameSize } from './types.ts'; @@ -77,8 +78,8 @@ describe('FfmpegCommandGenerator', () => { audioDuration: 11_000, }); - const subtitleState = SubtitleState.create({ - subtitleEncoder: 'srt', + const subtitleState = SubtitlesState.create({ + subtitlesEncoder: 'srt', }); const target = FrameSize.withDimensions(1280, 720); @@ -108,9 +109,9 @@ describe('FfmpegCommandGenerator', () => { audioState, ); - const subtitleInputFile = new SubtitleInputSource( + const subtitlesInputFile = new SubtitlesInputSource( videoInputFile.path, - [SubtitleStream.create({ encoder: 'srt' })], + [SubtitlesStream.create({ index: 1, codec: 'srt' })], subtitleState, ); @@ -136,7 +137,7 @@ describe('FfmpegCommandGenerator', () => { .setHardwareAccelerationMode('vaapi') .setVideoInputSource(videoInputFile) .setAudioInputSource(audioInputFile) - .setSubtitleInputSource(subtitleInputFile) + .setSubtitlesInputSource(subtitlesInputFile) .setWatermarkInputSource(watermarkInputFile) .build(); @@ -151,7 +152,7 @@ describe('FfmpegCommandGenerator', () => { const result = generator.generateArgs( videoInputFile, audioInputFile, - subtitleInputFile, + subtitlesInputFile, watermarkInputFile, steps, ); diff --git a/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts b/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts index 9920d2151..1c8b4b24f 100644 --- a/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts +++ b/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts @@ -6,7 +6,7 @@ import { BaseEncoder } from './encoder/BaseEncoder.ts'; import { ComplexFilter } from './filter/ComplexFilter.ts'; import { AudioInputSource } from './input/AudioInputSource.ts'; import { ConcatInputSource } from './input/ConcatInputSource.ts'; -import { SubtitleInputSource } from './input/SubtitleInputSource.ts'; +import { SubtitlesInputSource } from './input/SubtitlesInputSource.ts'; import { VideoInputSource } from './input/VideoInputSource.ts'; import { WatermarkInputSource } from './input/WatermarkInputSource.ts'; import { @@ -33,7 +33,7 @@ export class FfmpegCommandGenerator { generateArgs( videoInputSource: Nullable, audioInputSource: Nullable, - subtitleInputSource: Nullable, + subtitlesInputSource: Nullable, watermarkInputSource: Nullable, concatInputSource: Nullable, steps: PipelineStep[], @@ -62,12 +62,12 @@ export class FfmpegCommandGenerator { ); } - if (subtitleInputSource && !includedPaths.has(subtitleInputSource.path)) { - includedPaths.add(subtitleInputSource.path); + if (subtitlesInputSource && !includedPaths.has(subtitlesInputSource.path)) { + includedPaths.add(subtitlesInputSource.path); args.push( - ...subtitleInputSource.getInputOptions(), + ...subtitlesInputSource.getInputOptions(), '-i', - subtitleInputSource.path, + subtitlesInputSource.path, ); } diff --git a/server/src/ffmpeg/builder/MediaStream.ts b/server/src/ffmpeg/builder/MediaStream.ts index 872441852..fac912ca8 100644 --- a/server/src/ffmpeg/builder/MediaStream.ts +++ b/server/src/ffmpeg/builder/MediaStream.ts @@ -22,7 +22,7 @@ type MediaStreamFields = Omit< // semantics with class construction, but still enabling us // to have hierarchies, methods, etc. type AudioStreamFields = MediaStreamFields; -type SubtitleStreamFields = MediaStreamFields; +type SubtitlesStreamFields = MediaStreamFields; type VideoStreamFields = Omit, 'isAnamorphic'>; export class AudioStream implements MediaStream { @@ -42,20 +42,20 @@ export class AudioStream implements MediaStream { } } -export class SubtitleStream implements MediaStream { +export class SubtitlesStream implements MediaStream { readonly kind: StreamKind = 'subtitle'; index: number; codec: string; language?: string; - private constructor(fields: SubtitleStreamFields) { + private constructor(fields: SubtitlesStreamFields) { this.index = fields.index; this.codec = fields.codec; this.language = fields.language; } - static create(fields: SubtitleStreamFields) { - return new SubtitleStream(fields); + static create(fields: SubtitlesStreamFields) { + return new SubtitlesStream(fields); } } diff --git a/server/src/ffmpeg/builder/constants.ts b/server/src/ffmpeg/builder/constants.ts index f878c14e0..8488eb27d 100644 --- a/server/src/ffmpeg/builder/constants.ts +++ b/server/src/ffmpeg/builder/constants.ts @@ -25,6 +25,11 @@ export const AudioFormats = { Flac: 'flac', } as const; +export const SubtitlesFormats = { + Srt: 'srt', + AdvancedSubStationAlpha: 'ass', +} as const; + export const OutputLocation = { Stdout: 'stdout', } as const; diff --git a/server/src/ffmpeg/builder/input/SubtitleInputSource.ts b/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts similarity index 67% rename from server/src/ffmpeg/builder/input/SubtitleInputSource.ts rename to server/src/ffmpeg/builder/input/SubtitlesInputSource.ts index 516bbef28..423ab82ed 100644 --- a/server/src/ffmpeg/builder/input/SubtitleInputSource.ts +++ b/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts @@ -1,12 +1,12 @@ -import { SubtitleStream } from '@/ffmpeg/builder/MediaStream.ts'; +import { SubtitlesStream } from '@/ffmpeg/builder/MediaStream.ts'; import { InputSource, InputSourceContinuity, StreamSource, } from './InputSource.ts'; -export class SubtitleInputSource< - StreamType extends SubtitleStream = SubtitleStream, +export class SubtitlesInputSource< + StreamType extends SubtitlesStream = SubtitlesStream, > extends InputSource { readonly type = 'subtitle'; diff --git a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts index a877040a0..1dbe135d8 100644 --- a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts @@ -1,6 +1,6 @@ import { AudioStream, - SubtitleStream, + SubtitlesStream, VideoStream, } from '@/ffmpeg/builder/MediaStream.ts'; import { FfmpegCapabilities } from '@/ffmpeg/builder/capabilities/FfmpegCapabilities.ts'; @@ -36,7 +36,7 @@ import { UserAgentInputOption } from '@/ffmpeg/builder/options/input/UserAgentIn import { AudioState } from '@/ffmpeg/builder/state/AudioState.ts'; import { FfmpegState } from '@/ffmpeg/builder/state/FfmpegState.ts'; import { FrameState } from '@/ffmpeg/builder/state/FrameState.ts'; -import { SubtitleState } from '@/ffmpeg/builder/state/SubtitleState.ts'; +import { SubtitlesState } from '@/ffmpeg/builder/state/SubtitlesState.ts'; import { FrameDataLocation, HardwareAccelerationMode, @@ -107,7 +107,7 @@ import { } from '../options/OutputOption.ts'; import { Pipeline } from './Pipeline.ts'; import { PipelineBuilder } from './PipelineBuilder.ts'; -import { SubtitleInputSource } from '../input/SubtitleInputSource.ts'; +import { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts'; // Args passed to each setter -- we use an object here so we // 1. can deconstruct args in each implementor to use only what we need @@ -131,11 +131,11 @@ export type PipelineAudioFunctionArgs = { export type PipelineBuilderContext = { videoStream?: VideoStream; audioStream?: AudioStream; - subtitleStream?: SubtitleStream; + subtitleStream?: SubtitlesStream; ffmpegState: FfmpegState; desiredState: FrameState; desiredAudioState?: AudioState; - desiredSubtitleState?: SubtitleState; + desiredSubtitleState?: SubtitlesState; pipelineSteps: PipelineStep[]; filterChain: FilterChain; decoder: Nullable; @@ -194,7 +194,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { constructor( protected nullableVideoInputSource: Nullable, private audioInputSource: Nullable, - private subtitleInputSource: Nullable, + private subtitleInputSource: Nullable, protected watermarkInputSource: Nullable, protected concatInputSource: Nullable, protected ffmpegCapabilities: FfmpegCapabilities, @@ -569,7 +569,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return; } const encoder = new SubtitleEncoder( - this.context.desiredSubtitleState.subtitleEncoder, + this.context.desiredSubtitleState.subtitlesEncoder, ); this.pipelineSteps.push(encoder); } diff --git a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts index 8b23178d4..15a22399e 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts @@ -16,7 +16,7 @@ import { QsvPipelineBuilder } from './hardware/QsvPipelineBuilder.ts'; import { VaapiPipelineBuilder } from './hardware/VaapiPipelineBuilder.ts'; import { VideoToolboxPipelineBuilder } from './hardware/VideoToolboxPipelineBuilder.ts'; import { SoftwarePipelineBuilder } from './software/SoftwarePipelineBuilder.ts'; -import { SubtitleInputSource } from '../input/SubtitleInputSource.ts'; +import { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts'; export class PipelineBuilderFactory { constructor(private settingsDB: SettingsDB = getSettings()) {} @@ -32,7 +32,7 @@ export class PipelineBuilderFactory { class PipelineBuilderFactory$Builder { private videoInputSource: Nullable = null; private audioInputSource: Nullable = null; - private subtitleInputSource: Nullable = null; + private subtitlesInputSource: Nullable = null; private concatInputSource: Nullable = null; private watermarkInputSource: Nullable = null; private hardwareAccelerationMode: HardwareAccelerationMode = 'none'; @@ -56,10 +56,10 @@ class PipelineBuilderFactory$Builder { return this; } - setSubtitleInputSource( - subtitleInputSource: Nullable, + setSubtitlesInputSource( + subtitleInputSource: Nullable, ): PipelineBuilderFactory$Builder { - this.subtitleInputSource = subtitleInputSource; + this.subtitlesInputSource = subtitleInputSource; return this; } @@ -102,7 +102,7 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, - this.subtitleInputSource, + this.subtitlesInputSource, this.concatInputSource, this.watermarkInputSource, ); @@ -110,7 +110,7 @@ class PipelineBuilderFactory$Builder { return new QsvPipelineBuilder( this.videoInputSource, this.audioInputSource, - this.subtitleInputSource, + this.subtitlesInputSource, this.watermarkInputSource, this.concatInputSource, binaryCapabilities, @@ -121,7 +121,7 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, - this.subtitleInputSource, + this.subtitlesInputSource, this.watermarkInputSource, this.concatInputSource, ); @@ -131,7 +131,7 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, - this.subtitleInputSource, + this.subtitlesInputSource, this.concatInputSource, this.watermarkInputSource, ); @@ -139,7 +139,7 @@ class PipelineBuilderFactory$Builder { return new SoftwarePipelineBuilder( this.videoInputSource, this.audioInputSource, - this.subtitleInputSource, + this.subtitlesInputSource, this.watermarkInputSource, this.concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts index 7312916fe..7b89ccec6 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts @@ -1,6 +1,6 @@ import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitleInputSource } from '../input/SubtitleInputSource.ts'; +import { SubtitleInputSource } from '../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { Nullable } from '@/types/util.ts'; diff --git a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts index 7ae3abb54..6b9be9c9e 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts @@ -21,7 +21,7 @@ import { YadifCudaFilter } from '@/ffmpeg/builder/filter/nvidia/YadifCudaFilter. import { OverlayWatermarkFilter } from '@/ffmpeg/builder/filter/watermark/OverlayWatermarkFilter.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitleInputSource } from '../../input/SubtitleInputSource.ts'; +import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { PixelFormatOutputOption } from '@/ffmpeg/builder/options/OutputOption.ts'; @@ -54,14 +54,14 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, - subtitleInputFile: Nullable, + subtitlesInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, ) { super( videoInputFile, audioInputFile, - subtitleInputFile, + subtitlesInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts index 7e458e542..5579ef3a3 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts @@ -18,7 +18,7 @@ import { WatermarkOpacityFilter } from '@/ffmpeg/builder/filter/watermark/Waterm import { WatermarkScaleFilter } from '@/ffmpeg/builder/filter/watermark/WatermarkScaleFilter.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitleInputSource } from '../../input/SubtitleInputSource.ts'; +import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { VaapiDriverEnvironmentVariable } from '@/ffmpeg/builder/options/EnvironmentVariables.ts'; @@ -60,14 +60,14 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, - subtitleInputFile: Nullable, + subtitlesInputFile: Nullable, watermarkInputSource: Nullable, concatInputSource: Nullable, ) { super( videoInputFile, audioInputFile, - subtitleInputFile, + subtitlesInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts index 2b1a903f3..eea922244 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts @@ -7,7 +7,7 @@ import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.ts'; import { VideoToolboxHardwareAccelerationOption } from '@/ffmpeg/builder/filter/videotoolbox/VideoToolboxHardwareAccelerationOption.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitleInputSource } from '../../input/SubtitleInputSource.ts'; +import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { PixelFormatOutputOption } from '@/ffmpeg/builder/options/OutputOption.ts'; @@ -28,14 +28,14 @@ export class VideoToolboxPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, - subtitleInputFile: Nullable, + subtitlesInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, ) { super( videoInputFile, audioInputFile, - subtitleInputFile, + subtitlesInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/state/SubtitleState.ts b/server/src/ffmpeg/builder/state/SubtitleState.ts deleted file mode 100644 index 25d63d876..000000000 --- a/server/src/ffmpeg/builder/state/SubtitleState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ExcludeByValueType, Nullable } from '@/types/util.ts'; -import { isNil, omitBy } from 'lodash-es'; -import { AnyFunction } from 'ts-essentials'; - -export type SubtitleStateFields = ExcludeByValueType< - SubtitleState, - AnyFunction ->; - -const DefaultSubtitleState: SubtitleState = { - subtitleEncoder: 'srt', - subtitleLanguage: 'und', // 'und' for undefined - subtitleCodec: null, - subtitleFormat: null, - subtitleDelay: null, - subtitleDuration: null, - subtitleDefault: false, - subtitleForced: false, -}; - -export class SubtitleState { - subtitleEncoder: string; - subtitleLanguage: string; // Language code, e.g., 'en', 'es', 'fr' - subtitleCodec: Nullable; - subtitleFormat: Nullable; // Format like SRT, ASS, etc. - subtitleDelay: Nullable; // Delay in milliseconds - subtitleDuration: Nullable; // Duration in milliseconds - subtitleDefault: boolean; // Whether it's the default subtitle - subtitleForced: boolean; // Whether it's a forced subtitle - - private constructor(fields: Partial = {}) { - const merged: SubtitleStateFields = { - ...DefaultSubtitleState, - ...omitBy(fields, isNil), - }; - this.subtitleEncoder = merged.subtitleEncoder; - this.subtitleLanguage = merged.subtitleLanguage; - this.subtitleCodec = merged.subtitleCodec; - this.subtitleFormat = merged.subtitleFormat; - this.subtitleDelay = merged.subtitleDelay; - this.subtitleDuration = merged.subtitleDuration; - this.subtitleDefault = merged.subtitleDefault; - this.subtitleForced = merged.subtitleForced; - } - - static create(fields: Partial = {}) { - return new SubtitleState(fields); - } -} diff --git a/server/src/ffmpeg/builder/state/SubtitlesState.ts b/server/src/ffmpeg/builder/state/SubtitlesState.ts new file mode 100644 index 000000000..e2fce2127 --- /dev/null +++ b/server/src/ffmpeg/builder/state/SubtitlesState.ts @@ -0,0 +1,49 @@ +import { ExcludeByValueType, Nullable } from '@/types/util.ts'; +import { isNil, omitBy } from 'lodash-es'; +import { AnyFunction } from 'ts-essentials'; + +export type SubtitlesStateFields = ExcludeByValueType< + SubtitlesState, + AnyFunction +>; + +const DefaultSubtitlesState: SubtitlesState = { + subtitlesEncoder: 'srt', + subtitlesLanguage: 'und', // 'und' for undefined + subtitlesCodec: null, + subtitlesFormat: null, + subtitlesDelay: null, + subtitlesDuration: null, + subtitlesDefault: false, + subtitlesForced: false, +}; + +export class SubtitlesState { + subtitlesEncoder: string; + subtitlesLanguage: string; // Language code, e.g., 'en', 'es', 'fr' + subtitlesCodec: Nullable; + subtitlesFormat: Nullable; // Format like SRT, ASS, etc. + subtitlesDelay: Nullable; // Delay in milliseconds + subtitlesDuration: Nullable; // Duration in milliseconds + subtitlesDefault: boolean; // Whether it's the default subtitle + subtitlesForced: boolean; // Whether it's a forced subtitle + + private constructor(fields: Partial = {}) { + const merged: SubtitlesStateFields = { + ...DefaultSubtitlesState, + ...omitBy(fields, isNil), + }; + this.subtitlesEncoder = merged.subtitlesEncoder; + this.subtitlesLanguage = merged.subtitlesLanguage; + this.subtitlesCodec = merged.subtitlesCodec; + this.subtitlesFormat = merged.subtitlesFormat; + this.subtitlesDelay = merged.subtitlesDelay; + this.subtitlesDuration = merged.subtitlesDuration; + this.subtitlesDefault = merged.subtitlesDefault; + this.subtitlesForced = merged.subtitlesForced; + } + + static create(fields: Partial = {}) { + return new SubtitlesState(fields); + } +} diff --git a/server/src/migration/DirectMigrationProvider.ts b/server/src/migration/DirectMigrationProvider.ts index 47cea496e..6973fdb84 100644 --- a/server/src/migration/DirectMigrationProvider.ts +++ b/server/src/migration/DirectMigrationProvider.ts @@ -21,6 +21,7 @@ import LegacyMigration9 from './db/LegacyMigration9.ts'; import Migration1730806741 from './db/Migration1730806741.ts'; import Migration1731982492 from './db/Migration1731982492.ts'; import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts'; +import Migration1736426451_AddSubtitles from './db/Migration1736426451_AddSubtitles.ts'; export const LegacyMigrationNameToNewMigrationName = [ ['Migration20240124115044', '_Legacy_Migration00'], @@ -87,6 +88,7 @@ export class DirectMigrationProvider implements MigrationProvider { migration1731982492: Migration1731982492, migration1732969335: Migration1732969335_AddTranscodeConfig, migration1735044379: Migration1735044379_AddHlsDirect, + migration1736426451: Migration1736426451_AddSubtitles, }, wrapWithTransaction, ), diff --git a/server/src/migration/db/Migration1736426451_AddSubtitles.ts b/server/src/migration/db/Migration1736426451_AddSubtitles.ts new file mode 100644 index 000000000..7639d8396 --- /dev/null +++ b/server/src/migration/db/Migration1736426451_AddSubtitles.ts @@ -0,0 +1,13 @@ +import { Kysely, Migration } from 'kysely'; + +/** + * This migration adds the `subtitlesFormat` column to the `transcode_config` table. + */ +export default { + async up(db: Kysely) { + await db.schema + .alterTable('transcode_config') + .addColumn('subtitles_format', 'text') + .execute(); + }, +} satisfies Migration; diff --git a/server/src/serverContext.ts b/server/src/serverContext.ts index 42f5b3d54..d55e17f82 100644 --- a/server/src/serverContext.ts +++ b/server/src/serverContext.ts @@ -15,6 +15,7 @@ import { FileCacheService } from './services/FileCacheService.ts'; import { HdhrService } from './services/HDHRService.ts'; import { HealthCheckService } from './services/HealthCheckService.js'; import { M3uService } from './services/M3UService.ts'; +import { SubtitlesService } from './services/SubtitlesService.ts'; import { OnDemandChannelService } from './services/OnDemandChannelService.js'; import { TVGuideService } from './services/TvGuideService.ts'; import { XmlTvWriter } from './services/XmlTvWriter.ts'; @@ -26,6 +27,7 @@ import { StreamProgramCalculator } from './stream/StreamProgramCalculator.js'; export class ServerContext { public readonly programConverter = new ProgramConverter(); public readonly sessionManager: SessionManager; + public readonly subtitlesService: SubtitlesService; public readonly onDemandChannelService: OnDemandChannelService; public readonly healthCheckService: HealthCheckService; diff --git a/server/src/services/SubtitlesService.ts b/server/src/services/SubtitlesService.ts new file mode 100644 index 000000000..941c496df --- /dev/null +++ b/server/src/services/SubtitlesService.ts @@ -0,0 +1,165 @@ +import { ChannelDB } from '@/db/ChannelDB.ts'; +import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; +import { MutexMap } from '@/util/mutexMap.js'; +import dayjs from 'dayjs'; +import { isNull, isUndefined } from 'lodash-es'; + +export class SubtitlesService { + #logger = LoggerFactory.child({ className: this.constructor.name }); + #locks: MutexMap = new MutexMap(); + + constructor(private channelDB: ChannelDB) {} + + async isChannelPlaying(id: string) { + const channelAndLineup = await this.channelDB.loadChannelAndLineup(id); + if (isNull(channelAndLineup)) { + return false; + } + + const { lineup } = channelAndLineup; + + return lineup.subtitlesConfig?.state === 'playing'; + } + + async pauseAllChannels() { + const allConfigs = await this.channelDB.loadAllLineupConfigs(); + const now = dayjs().unix() * 1000; + for (const [channelId, { lineup }] of Object.entries(allConfigs)) { + if (isUndefined(lineup.onDemandConfig)) { + continue; + } + + if (lineup.onDemandConfig.state === 'paused') { + continue; + } + + await this.channelDB.updateLineupConfig(channelId, 'onDemandConfig', { + ...lineup.onDemandConfig, + state: 'paused', + lastPaused: now, + }); + } + } + + async pauseChannel(id: string, stopTime?: number, rewindMs: number = 0) { + return this.#locks.runWithLockId(id, async () => { + const channelAndLineup = await this.loadOnDemandChannelLineup(id); + + if (isUndefined(channelAndLineup)) { + return; + } + + const { channel, lineup } = channelAndLineup; + + if (isUndefined(lineup.onDemandConfig)) { + return; + } + + if (lineup.onDemandConfig.state === 'paused') { + return; + } + + // TODO: What happens if the channel lineup is modified whille + // the stream is active? + const pauseTime = stopTime ?? dayjs().valueOf(); + const lastResumed = + lineup.onDemandConfig.lastResumed ?? channel.startTime; + const elapsed = pauseTime - lastResumed; + // If the channel was updated after the channel was resumed, zero out the + // cursor and start the channel from the beginning. + const nextCursor = + lineup.lastUpdated > lastResumed + ? 0 + : (lineup.onDemandConfig.cursor + elapsed - rewindMs) % + channel.duration; + + return await this.channelDB + .updateLineupConfig(id, 'onDemandConfig', { + ...(lineup.onDemandConfig ?? {}), + state: 'paused', + lastPaused: pauseTime, + cursor: nextCursor, + }) + .finally(() => { + this.#logger.debug( + 'Paused on-demand channel %s (at = %s)', + id, + dayjs(pauseTime).format(), + ); + }); + }); + } + + async resumeChannel(id: string) { + return this.#locks.runWithLockId(id, async () => { + const channelAndLineup = await this.loadOnDemandChannelLineup(id); + + if (isUndefined(channelAndLineup)) { + return; + } + + const { lineup } = channelAndLineup; + + if (isUndefined(lineup.onDemandConfig)) { + return; + } + + if (lineup.onDemandConfig.state === 'playing') { + return; + } + + // TODO: Find the current program at the last cursor + // and skip it if it's a commercial. + + const now = dayjs(); + return await this.channelDB + .updateLineupConfig(id, 'onDemandConfig', { + ...(lineup.onDemandConfig ?? {}), + state: 'playing', + lastResumed: +now, + }) + .finally(() => { + this.#logger.debug( + 'Resumed on-demand channel %s (at = %s)', + id, + now.format(), + ); + }); + }); + } + + async getLiveTimestamp(channelId: string, requestTime: number) { + const channelAndLineup = await this.loadOnDemandChannelLineup(channelId); + + if (isUndefined(channelAndLineup)) { + return requestTime; + } + + const { channel, lineup } = channelAndLineup; + + if (isUndefined(lineup.onDemandConfig)) { + return requestTime; + } + + let sinceResume = dayjs(requestTime).diff( + dayjs(lineup.onDemandConfig.lastResumed), + ); + + // Don't skip milliseconds + if (sinceResume < 1_000) { + sinceResume = 0; + } + + return channel.startTime + lineup.onDemandConfig.cursor + sinceResume; + } + + private async loadOnDemandChannelLineup(id: string) { + const channelAndLineup = + await this.channelDB.loadDirectChannelAndLineup(id); + if (isNull(channelAndLineup)) { + return; + } + + return channelAndLineup; + } +} diff --git a/server/src/stream/types.ts b/server/src/stream/types.ts index 0a519751b..f4d40f68d 100644 --- a/server/src/stream/types.ts +++ b/server/src/stream/types.ts @@ -19,6 +19,7 @@ export type StreamDetails = { // If defined, there is at least one video stream videoDetails?: [VideoStreamDetails, ...VideoStreamDetails[]]; audioDetails?: [AudioStreamDetails, ...AudioStreamDetails[]]; + subtitlesDetails?: [SubtitlesStreamDetails, ...SubtitlesStreamDetails[]]; audioOnly?: boolean; placeholderImage?: string; @@ -56,6 +57,19 @@ export type AudioStreamDetails = { forced?: boolean; }; +export type SubtitlesStreamDetails = { + index?: string; + channels?: number; + codec?: string; + bitrate?: number; + profile?: string; + default?: boolean; + selected?: boolean; + title?: string; + language?: string; + forced?: boolean; +}; + // TODO: Move me export function getPixelFormatForStream(details: StreamDetails) { if (isUndefined(first(details.videoDetails))) { diff --git a/types/src/schemas/channelSchema.ts b/types/src/schemas/channelSchema.ts index 0ddf09b4a..c65f74dae 100644 --- a/types/src/schemas/channelSchema.ts +++ b/types/src/schemas/channelSchema.ts @@ -98,6 +98,9 @@ export const ChannelSchema = z.object({ stealth: z.boolean(), transcoding: ChannelTranscodingOptionsSchema.optional(), watermark: WatermarkSchema.optional(), + subtitles: z.object({ + enabled: z.boolean(), + }), onDemand: z.object({ enabled: z.boolean(), }), diff --git a/web/src/components/channel_config/ChannelPropertiesEditor.tsx b/web/src/components/channel_config/ChannelPropertiesEditor.tsx index 618cc837c..eec0be741 100644 --- a/web/src/components/channel_config/ChannelPropertiesEditor.tsx +++ b/web/src/components/channel_config/ChannelPropertiesEditor.tsx @@ -227,6 +227,32 @@ export function ChannelPropertiesEditor({ isNew }: Props) { /> + + + Subtitles + + + Enable subtitles for channel media where available. +
+ NOTE: Only external subtitle files are are + supported at this time. +
+ ( + field.onChange(e.target.checked)} + /> + } + label="Enabled" + /> + )} + /> +
On-Demand diff --git a/web/src/components/channel_config/EditChannelForm.tsx b/web/src/components/channel_config/EditChannelForm.tsx index 1b5971ade..92c890141 100644 --- a/web/src/components/channel_config/EditChannelForm.tsx +++ b/web/src/components/channel_config/EditChannelForm.tsx @@ -71,6 +71,9 @@ function getDefaultFormValues(channel: Channel): SaveChannelRequest { }, ], }, + subtitles: { + enabled: channel.subtitles?.enabled ?? false, + }, onDemand: { enabled: channel.onDemand.enabled, }, @@ -90,7 +93,7 @@ export function EditChannelForm({ const formMethods = useForm({ mode: 'onChange', // Change this so we only load the form on initial... - // eslint-disable-next-line @typescript-eslint/require-await + defaultValues: getDefaultFormValues(channel), }); From aa483eeee440975a369197d7c51154417cbf634d Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Tue, 7 Jan 2025 21:10:19 -0500 Subject: [PATCH 03/24] fix: more work towards good subs --- server/src/ffmpeg/builder/MediaStream.ts | 2 +- .../src/ffmpeg/builder/encoder/BaseEncoder.ts | 2 +- .../src/ffmpeg/builder/input/InputSource.ts | 2 +- .../builder/input/SubtitlesInputSource.ts | 4 ++- server/src/ffmpeg/builder/types.ts | 2 +- .../stream/local/LocalFileStreamDetails.ts | 29 +++++++++++++++++-- server/src/types/ffmpeg.ts | 11 +++++++ .../channel_config/MediaGridItem.tsx | 22 +++++++------- 8 files changed, 55 insertions(+), 19 deletions(-) diff --git a/server/src/ffmpeg/builder/MediaStream.ts b/server/src/ffmpeg/builder/MediaStream.ts index fac912ca8..bdea33fbf 100644 --- a/server/src/ffmpeg/builder/MediaStream.ts +++ b/server/src/ffmpeg/builder/MediaStream.ts @@ -43,7 +43,7 @@ export class AudioStream implements MediaStream { } export class SubtitlesStream implements MediaStream { - readonly kind: StreamKind = 'subtitle'; + readonly kind: StreamKind = 'subtitles'; index: number; codec: string; language?: string; diff --git a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts index 5126281e4..411b6ecd4 100644 --- a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts +++ b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts @@ -48,7 +48,7 @@ export class AudioEncoder extends BaseEncoder { export class SubtitleEncoder extends BaseEncoder { constructor(encoder: string) { - super(encoder, 'subtitle'); + super(encoder, 'subtitles'); } } diff --git a/server/src/ffmpeg/builder/input/InputSource.ts b/server/src/ffmpeg/builder/input/InputSource.ts index aa5a620a4..51459cb76 100644 --- a/server/src/ffmpeg/builder/input/InputSource.ts +++ b/server/src/ffmpeg/builder/input/InputSource.ts @@ -10,7 +10,7 @@ import { } from '../../../stream/types.ts'; import { VideoInputSource } from './VideoInputSource.ts'; -export type InputSourceType = 'video' | 'audio' | 'subtitle'; +export type InputSourceType = 'video' | 'audio' | 'subtitles'; export type InputSourceProtocol = 'file' | 'http' | 'filter'; export type InputSourceContinuity = 'discrete' | 'infinite'; diff --git a/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts b/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts index 423ab82ed..501b79d1a 100644 --- a/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts +++ b/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts @@ -1,4 +1,5 @@ import { SubtitlesStream } from '@/ffmpeg/builder/MediaStream.ts'; +import { SubtitlesState } from '../state/SubtitlesState.ts'; import { InputSource, InputSourceContinuity, @@ -8,11 +9,12 @@ import { export class SubtitlesInputSource< StreamType extends SubtitlesStream = SubtitlesStream, > extends InputSource { - readonly type = 'subtitle'; + readonly type = 'subtitles'; constructor( source: StreamSource, public streams: StreamType[], + public desiredState: SubtitlesState, continuity: InputSourceContinuity = 'discrete', ) { super(source, continuity); diff --git a/server/src/ffmpeg/builder/types.ts b/server/src/ffmpeg/builder/types.ts index 6e3e894c7..6c6e695b2 100644 --- a/server/src/ffmpeg/builder/types.ts +++ b/server/src/ffmpeg/builder/types.ts @@ -33,7 +33,7 @@ export const StreamKinds = [ 'video', 'all', 'stillimage', - 'subtitle', + 'subtitles', ] as const; export type StreamKind = TupleToUnion; diff --git a/server/src/stream/local/LocalFileStreamDetails.ts b/server/src/stream/local/LocalFileStreamDetails.ts index 9b0e1ffd9..e45b354a6 100644 --- a/server/src/stream/local/LocalFileStreamDetails.ts +++ b/server/src/stream/local/LocalFileStreamDetails.ts @@ -1,6 +1,10 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.ts'; -import { FfprobeAudioStream, FfprobeVideoStream } from '@/types/ffmpeg.ts'; +import { + FfprobeAudioStream, + FfprobeVideoStream, + FfprobeSubtitlesStream, +} from '@/types/ffmpeg.ts'; import { Maybe, Nullable } from '@/types/util.ts'; import dayjs from '@/util/dayjs.ts'; import { fileExists } from '@/util/fsUtil.ts'; @@ -13,6 +17,7 @@ import { FileStreamSource, HttpStreamSource, ProgramStreamResult, + SubtitlesStreamDetails, VideoStreamDetails, } from '../types.ts'; @@ -53,8 +58,8 @@ export class LocalFileStreamDetails { videoStream.field_order === 'interlaced' ? 'interlaced' : videoStream.field_order === 'progressive' - ? 'progressive' - : 'unknown', + ? 'progressive' + : 'unknown', width: videoStream.width, height: videoStream.height, framerate: videoStream.r_frame_rate ?? undefined, @@ -90,12 +95,30 @@ export class LocalFileStreamDetails { }, ); + const subtitlesStreamDetails = map( + filter( + probeResult.streams, + (stream): stream is FfprobeSubtitlesStream => + stream.codec_type === 'subtitles', + ), + (subtitlesStream) => { + return { + codec: subtitlesStream.codec_name, + index: subtitlesStream.index.toFixed(), + profile: subtitlesStream.profile, + } satisfies SubtitlesStreamDetails; + }, + ); + return { streamDetails: { videoDetails: videoDetails ? [videoDetails] : undefined, audioDetails: isEmpty(audioStreamDetails) ? undefined : (audioStreamDetails as NonEmptyArray), + subtitlesDetails: isEmpty(subtitlesStreamDetails) + ? undefined + : (subtitlesStreamDetails as NonEmptyArray), duration: dayjs.duration({ seconds: probeResult.format.duration }), }, streamSource: this.path.startsWith('http') diff --git a/server/src/types/ffmpeg.ts b/server/src/types/ffmpeg.ts index 8ddde61a8..42501bd5e 100644 --- a/server/src/types/ffmpeg.ts +++ b/server/src/types/ffmpeg.ts @@ -58,6 +58,16 @@ export const FfprobeAudioStreamSchema = BaseFfprobeMediaStreamSchema.extend({ export type FfprobeAudioStream = z.infer; +export const FfprobeSubtitlesStreamSchema = BaseFfprobeMediaStreamSchema.extend( + { + codec_type: z.literal('subtitles'), + }, +); + +export type FfprobeSubtitlesStream = z.infer< + typeof FfprobeSubtitlesStreamSchema +>; + function parsePossibleFractionToFloat(s: string) { if (s.includes('/')) { const [num, den] = split(s, '/', 2); @@ -74,6 +84,7 @@ function parsePossibleFractionToFloat(s: string) { export const FfprobeMediaStreamSchema = z.discriminatedUnion('codec_type', [ FfprobeVideoStreamSchema, FfprobeAudioStreamSchema, + FfprobeSubtitlesStreamSchema, ]); export const FfprobeMediaFormatSchema = z.object({ diff --git a/web/src/components/channel_config/MediaGridItem.tsx b/web/src/components/channel_config/MediaGridItem.tsx index c1139fcad..ec3a5fa55 100644 --- a/web/src/components/channel_config/MediaGridItem.tsx +++ b/web/src/components/channel_config/MediaGridItem.tsx @@ -38,7 +38,7 @@ export type GridItemMetadata = { childCount: number | null; aspectRatio: 'portrait' | 'landscape' | 'square'; title: string; - subtitle: JSX.Element | string | null; + subtitles: JSX.Element | string | null; thumbnailUrl: string; selectedMedia: SelectedMedia; }; @@ -75,7 +75,7 @@ const MediaGridItemInner = ( selectedMedia: selectedMediaItem, aspectRatio, title, - subtitle, + subtitles, childCount, }, style, @@ -171,8 +171,8 @@ const MediaGridItemInner = ( aspectRatio === 'square' ? 100 : aspectRatio === 'landscape' - ? 84 - : 225, // 84 accomodates episode img height + ? 84 + : 225, // 84 accomodates episode img height maxHeight: '100%', }} > @@ -203,8 +203,8 @@ const MediaGridItemInner = ( aspectRatio === 'square' ? '1/1' : aspectRatio === 'landscape' - ? '1.77/1' - : '2/3', + ? '1.77/1' + : '2/3', width: '100%', height: 'auto', zIndex: 1, @@ -215,8 +215,8 @@ const MediaGridItemInner = ( aspectRatio === 'square' ? 100 : aspectRatio === 'landscape' - ? 84 - : 225, + ? 84 + : 225, }} > @@ -229,14 +229,14 @@ const MediaGridItemInner = ( aspectRatio === 'square' ? 144 : aspectRatio === 'landscape' - ? 84 - : 250 + ? 84 + : 250 } /> ))} Date: Wed, 8 Jan 2025 17:30:37 -0500 Subject: [PATCH 04/24] feat: fixup refs --- server/src/db/TranscodeConfigDB.ts | 1 + server/src/db/schema/TranscodeConfig.ts | 5 --- server/src/ffmpeg/FfmpegStreamFactory.ts | 2 +- .../src/ffmpeg/builder/encoder/BaseEncoder.ts | 2 +- .../builder/pipeline/BasePipelineBuilder.ts | 32 +++++++++---------- .../src/ffmpeg/builder/pipeline/Pipeline.ts | 2 +- .../ffmpeg/builder/pipeline/PipelineInputs.ts | 4 +-- .../src/migration/DirectMigrationProvider.ts | 2 -- .../Migration1732969335_AddTranscodeConfig.ts | 6 ++++ .../db/Migration1736426451_AddSubtitles.ts | 13 -------- .../legacy_migration/legacyDbMigration.ts | 6 ++++ .../stream/jellyfin/JellyfinStreamDetails.ts | 28 ++++++++++++++++ types/src/schemas/settingsSchemas.ts | 1 + types/src/schemas/transcodeConfigSchemas.ts | 3 ++ 14 files changed, 66 insertions(+), 41 deletions(-) delete mode 100644 server/src/migration/db/Migration1736426451_AddSubtitles.ts diff --git a/server/src/db/TranscodeConfigDB.ts b/server/src/db/TranscodeConfigDB.ts index 94b031631..62de58331 100644 --- a/server/src/db/TranscodeConfigDB.ts +++ b/server/src/db/TranscodeConfigDB.ts @@ -203,6 +203,7 @@ export class TranscodeConfigDB { hardwareAccelerationMode: 'none', normalizeFrameRate: booleanToNumber(false), deinterlaceVideo: booleanToNumber(true), + subtitlesFormat: 'srt', videoBitRate: 3500, videoBufferSize: 3500 * 2, videoFormat: 'h264', diff --git a/server/src/db/schema/TranscodeConfig.ts b/server/src/db/schema/TranscodeConfig.ts index 0ef9d432e..d96ffcedf 100644 --- a/server/src/db/schema/TranscodeConfig.ts +++ b/server/src/db/schema/TranscodeConfig.ts @@ -174,15 +174,11 @@ export const transcodeConfigFromLegacySettings = ( const audioSetting = TranscodeAudioOutputFormats.find( (fmt) => legacySettings.audioEncoder === fmt, ); - const subtitlesSetting = TranscodeSubtitlesOutputFormats.find( - (fmt) => legacySettings.subtitlesFormat === fmt, - ); const videoSetting = TranscodeVideoOutputFormats.find( (fmt) => legacySettings.videoFormat === fmt, ); const audioFormat = audioSetting ?? 'aac'; - const subtitlesFormat = subtitlesSetting ?? 'srt'; const videoFormat = videoSetting ?? 'h264'; return { @@ -200,7 +196,6 @@ export const transcodeConfigFromLegacySettings = ( ), threadCount: legacySettings.numThreads, uuid: v4(), - subtitlesFormat, videoBitRate: legacySettings.videoBitrate, videoBufferSize: legacySettings.videoBufferSize, videoFormat, diff --git a/server/src/ffmpeg/FfmpegStreamFactory.ts b/server/src/ffmpeg/FfmpegStreamFactory.ts index 90025fe8e..25c0c4eac 100644 --- a/server/src/ffmpeg/FfmpegStreamFactory.ts +++ b/server/src/ffmpeg/FfmpegStreamFactory.ts @@ -244,7 +244,7 @@ export class FfmpegStreamFactory extends IFFMPEG { ...pipeline.inputs, videoInput: null, audioInput: null, - subtitleInput: null, + subtitlesInput: null, }); return new FfmpegTranscodeSession( diff --git a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts index 411b6ecd4..ced772964 100644 --- a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts +++ b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts @@ -46,7 +46,7 @@ export class AudioEncoder extends BaseEncoder { } } -export class SubtitleEncoder extends BaseEncoder { +export class SubtitlesEncoder extends BaseEncoder { constructor(encoder: string) { super(encoder, 'subtitles'); } diff --git a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts index 1dbe135d8..e7dee5474 100644 --- a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts @@ -8,7 +8,7 @@ import { Decoder } from '@/ffmpeg/builder/decoder/Decoder.ts'; import { DecoderFactory } from '@/ffmpeg/builder/decoder/DecoderFactory.ts'; import { AudioEncoder, - SubtitleEncoder, + SubtitlesEncoder, VideoEncoder, } from '@/ffmpeg/builder/encoder/BaseEncoder.ts'; import { Encoder } from '@/ffmpeg/builder/encoder/Encoder.ts'; @@ -131,11 +131,11 @@ export type PipelineAudioFunctionArgs = { export type PipelineBuilderContext = { videoStream?: VideoStream; audioStream?: AudioStream; - subtitleStream?: SubtitlesStream; + subtitlesStream?: SubtitlesStream; ffmpegState: FfmpegState; desiredState: FrameState; desiredAudioState?: AudioState; - desiredSubtitleState?: SubtitlesState; + desiredSubtitlesState?: SubtitlesState; pipelineSteps: PipelineStep[]; filterChain: FilterChain; decoder: Nullable; @@ -157,7 +157,7 @@ export type PipelineBuilderContextWithAudio = MarkRequired< >; export type PipelineBuilderContextWithSubtitles = MarkRequired< PipelineBuilderContext, - 'subtitleStream' | 'desiredSubtitleState' + 'subtitlesStream' | 'desiredSubtitlesState' >; export function isVideoPipelineContext( @@ -174,12 +174,12 @@ export function isAudioPipelineContext( ); } -export function isSubtitlePipelineContext( +export function isSubtitlesPipelineContext( context: PipelineBuilderContext, ): context is PipelineBuilderContextWithSubtitles { return ( - !isUndefined(context.subtitleStream) && - !isUndefined(context.desiredSubtitleState) + !isUndefined(context.subtitlesStream) && + !isUndefined(context.desiredSubtitlesState) ); } @@ -194,7 +194,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { constructor( protected nullableVideoInputSource: Nullable, private audioInputSource: Nullable, - private subtitleInputSource: Nullable, + private subtitlesInputSource: Nullable, protected watermarkInputSource: Nullable, protected concatInputSource: Nullable, protected ffmpegCapabilities: FfmpegCapabilities, @@ -253,7 +253,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(pipelineSteps, { videoInput: null, audioInput: null, - subtitleInput: null, + subtitlesInput: null, concatInput: input, watermarkInput: null, }); @@ -294,7 +294,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(pipelineSteps, { videoInput: null, audioInput: null, - subtitleInput: null, + subtitlesInput: null, concatInput: input, watermarkInput: null, }); @@ -304,7 +304,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { this.context = { videoStream: first(this.videoInputSource.streams), audioStream: first(this.audioInputSource?.streams), - subtitleStream: first(this.subtitleInputSource?.streams), + subtitlesStream: first(this.subtitlesInputSource?.streams), ffmpegState, desiredState, desiredAudioState: this.audioInputSource?.desiredState, @@ -446,7 +446,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { new ComplexFilter( this.videoInputSource, this.audioInputSource, - this.subtitleInputSource, + this.subtitlesInputSource, this.watermarkInputSource, this.context.filterChain, ), @@ -459,7 +459,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(this.pipelineSteps, { videoInput: this.videoInputSource, audioInput: this.audioInputSource, - subtitleInput: this.subtitleInputSource, + subtitlesInput: this.subtitlesInputSource, watermarkInput: this.watermarkInputSource, concatInput: this.concatInputSource, }); @@ -565,11 +565,11 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { } protected buildSubtitlePipeline() { - if (!isSubtitlePipelineContext(this.context)) { + if (!isSubtitlesPipelineContext(this.context)) { return; } - const encoder = new SubtitleEncoder( - this.context.desiredSubtitleState.subtitlesEncoder, + const encoder = new SubtitlesEncoder( + this.context.desiredSubtitlesState.subtitlesEncoder, ); this.pipelineSteps.push(encoder); } diff --git a/server/src/ffmpeg/builder/pipeline/Pipeline.ts b/server/src/ffmpeg/builder/pipeline/Pipeline.ts index 49cf22468..b64be6c49 100644 --- a/server/src/ffmpeg/builder/pipeline/Pipeline.ts +++ b/server/src/ffmpeg/builder/pipeline/Pipeline.ts @@ -18,7 +18,7 @@ export class Pipeline { return this.#commandGenerator.generateArgs( this.inputs.videoInput, this.inputs.audioInput, - this.inputs.subtitleInput, + this.inputs.subtitlesInput, this.inputs.watermarkInput, this.inputs.concatInput, this.steps, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts index 7b89ccec6..a57743661 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts @@ -1,6 +1,6 @@ import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitleInputSource } from '../input/SubtitlesInputSource.ts'; +import { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { Nullable } from '@/types/util.ts'; @@ -8,7 +8,7 @@ import { Nullable } from '@/types/util.ts'; export type PipelineInputs = { videoInput: Nullable; audioInput: Nullable; - subtitleInput: Nullable; + subtitlesInput: Nullable; watermarkInput: Nullable; concatInput: Nullable; }; diff --git a/server/src/migration/DirectMigrationProvider.ts b/server/src/migration/DirectMigrationProvider.ts index 6973fdb84..47cea496e 100644 --- a/server/src/migration/DirectMigrationProvider.ts +++ b/server/src/migration/DirectMigrationProvider.ts @@ -21,7 +21,6 @@ import LegacyMigration9 from './db/LegacyMigration9.ts'; import Migration1730806741 from './db/Migration1730806741.ts'; import Migration1731982492 from './db/Migration1731982492.ts'; import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts'; -import Migration1736426451_AddSubtitles from './db/Migration1736426451_AddSubtitles.ts'; export const LegacyMigrationNameToNewMigrationName = [ ['Migration20240124115044', '_Legacy_Migration00'], @@ -88,7 +87,6 @@ export class DirectMigrationProvider implements MigrationProvider { migration1731982492: Migration1731982492, migration1732969335: Migration1732969335_AddTranscodeConfig, migration1735044379: Migration1735044379_AddHlsDirect, - migration1736426451: Migration1736426451_AddSubtitles, }, wrapWithTransaction, ), diff --git a/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts b/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts index 5ff0b8c6d..85be8d6b8 100644 --- a/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts +++ b/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts @@ -2,6 +2,7 @@ import { getSettings } from '@/db/SettingsDB.ts'; import { NewTranscodeConfig, TranscodeAudioOutputFormats, + TranscodeSubtitlesOutputFormats, TranscodeVideoOutputFormats, } from '@/db/schema/TranscodeConfig.ts'; import { DB } from '@/db/schema/db.ts'; @@ -85,6 +86,7 @@ export default { .addColumn('is_default', 'boolean', (col) => col.notNull().defaultTo(false), ) + .addColumn('subtitles_format', 'text') .execute(); await db.schema @@ -103,6 +105,9 @@ export default { const audioSetting = TranscodeAudioOutputFormats.find( (fmt) => existingFfmpegSettings.audioEncoder === fmt, ); + const subtitlesSetting = TranscodeSubtitlesOutputFormats.find( + (fmt) => existingFfmpegSettings.subtitlesEncoder === fmt, + ); const videoSetting = TranscodeVideoOutputFormats.find( (fmt) => existingFfmpegSettings.videoFormat === fmt, ); @@ -135,6 +140,7 @@ export default { vaapiDevice: existingFfmpegSettings.vaapiDevice, videoBitDepth: 8, isDefault: booleanToNumber(true), + subtitlesFormat: subtitlesSetting ?? 'srt', }; const transcodeConfigId = ( diff --git a/server/src/migration/db/Migration1736426451_AddSubtitles.ts b/server/src/migration/db/Migration1736426451_AddSubtitles.ts deleted file mode 100644 index 7639d8396..000000000 --- a/server/src/migration/db/Migration1736426451_AddSubtitles.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Kysely, Migration } from 'kysely'; - -/** - * This migration adds the `subtitlesFormat` column to the `transcode_config` table. - */ -export default { - async up(db: Kysely) { - await db.schema - .alterTable('transcode_config') - .addColumn('subtitles_format', 'text') - .execute(); - }, -} satisfies Migration; diff --git a/server/src/migration/legacy_migration/legacyDbMigration.ts b/server/src/migration/legacy_migration/legacyDbMigration.ts index 5ba4ea07c..57f7b0334 100644 --- a/server/src/migration/legacy_migration/legacyDbMigration.ts +++ b/server/src/migration/legacy_migration/legacyDbMigration.ts @@ -3,6 +3,7 @@ import { NewCachedImage } from '@/db/schema/CachedImage.ts'; import { NewTranscodeConfig, TranscodeAudioOutputFormats, + TranscodeSubtitlesOutputFormats, TranscodeVideoOutputFormats, } from '@/db/schema/TranscodeConfig.ts'; import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.ts'; @@ -444,6 +445,7 @@ export class LegacyDbMigrator { videoFormat, hardwareAccelerationMode: hwAccel, audioEncoder: ffmpegSettings['audioEncoder'] as string, + subtitlesEncoder: ffmpegSettings['subtitlesEncoder'] as string, targetResolution: tryParseResolution( ffmpegSettings['targetResolution'] as string, @@ -496,6 +498,9 @@ export class LegacyDbMigrator { const audioSetting = TranscodeAudioOutputFormats.find( (fmt) => newFfmpegSettings.audioEncoder === fmt, ); + const subtitlesSetting = TranscodeSubtitlesOutputFormats.find( + (fmt) => newFfmpegSettings.subtitlesEncoder === fmt, + ); const videoSetting = TranscodeVideoOutputFormats.find( (fmt) => newFfmpegSettings.videoFormat === fmt, ); @@ -512,6 +517,7 @@ export class LegacyDbMigrator { resolution: JSON.stringify( newFfmpegSettings.targetResolution satisfies Resolution, ), + subtitlesFormat: subtitlesSetting ?? 'srt', threadCount: newFfmpegSettings.numThreads, uuid: v4(), videoBitRate: newFfmpegSettings.videoBitrate, diff --git a/server/src/stream/jellyfin/JellyfinStreamDetails.ts b/server/src/stream/jellyfin/JellyfinStreamDetails.ts index d3807442d..bbd01ead3 100644 --- a/server/src/stream/jellyfin/JellyfinStreamDetails.ts +++ b/server/src/stream/jellyfin/JellyfinStreamDetails.ts @@ -42,6 +42,7 @@ import { ProgramStreamResult, StreamDetails, StreamSource, + SubtitlesStreamDetails, VideoStreamDetails, } from '../types.js'; @@ -284,6 +285,30 @@ export class JellyfinStreamDetails { }, ); + const subtitlesStreamDetails = map( + sortBy( + filter( + firstMediaSource?.MediaStreams, + (stream) => stream.Type === 'Subtitle', + ), + (stream) => [stream.Index ?? 0, !stream.IsDefault], + ), + (subtitlesStream) => { + return { + codec: nullToUndefined(subtitlesStream.Codec), + index: + ifDefined(subtitlesStream.Index, (streamIndex) => { + const index = streamIndex - externalStreamCount; + if (index >= 0) { + return index.toString(); + } + return; + }) ?? undefined, + profile: nullToUndefined(subtitlesStream.Profile), + } satisfies SubtitlesStreamDetails; + }, + ); + if (!videoStreamDetails && isEmpty(audioStreamDetails)) { this.logger.warn( 'Could not find a video nor audio stream for Plex item %s', @@ -301,6 +326,9 @@ export class JellyfinStreamDetails { audioDetails: isEmpty(audioStreamDetails) ? undefined : (audioStreamDetails as NonEmptyArray), + subtitlesDetails: isEmpty(subtitlesStreamDetails) + ? undefined + : (subtitlesStreamDetails as NonEmptyArray), }; if (audioOnly) { diff --git a/types/src/schemas/settingsSchemas.ts b/types/src/schemas/settingsSchemas.ts index b37258e2e..47b6eb3d5 100644 --- a/types/src/schemas/settingsSchemas.ts +++ b/types/src/schemas/settingsSchemas.ts @@ -94,6 +94,7 @@ export const FfmpegSettingsSchema = z.object({ .union([z.literal('h264'), z.literal('hevc'), z.literal('mpeg2')]) .default(DefaultVideoFormat), audioEncoder: z.string().default('aac'), + subtitlesEncoder: z.string().default('srt'), targetResolution: ResolutionSchema.default({ widthPx: 1920, heightPx: 1080 }), videoBitrate: z.number().default(10000), videoBufferSize: z.number().default(1000), diff --git a/types/src/schemas/transcodeConfigSchemas.ts b/types/src/schemas/transcodeConfigSchemas.ts index f0faab82c..1ee53f80d 100644 --- a/types/src/schemas/transcodeConfigSchemas.ts +++ b/types/src/schemas/transcodeConfigSchemas.ts @@ -37,6 +37,8 @@ export type SupportedTranscodeAudioOutputFormats = TupleToUnion< typeof SupportedTranscodeAudioOutputFormats >; +export const SupportedTranscodeSubtitlesOutputFormats = ['ass', 'srt'] as const; + export const TranscodeConfigSchema = z.object({ id: z.string(), name: z.string(), @@ -57,6 +59,7 @@ export const TranscodeConfigSchema = z.object({ audioBufferSize: z.number(), audioSampleRate: z.number(), audioVolumePercent: z.number().default(100), + subtitlesFormat: z.enum(SupportedTranscodeSubtitlesOutputFormats), normalizeFrameRate: z.boolean(), deinterlaceVideo: z.boolean(), disableChannelOverlay: z.boolean(), From c87df49756988c9f18b1a5b33af3e1dfd7c06076 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Thu, 9 Jan 2025 18:53:51 -0500 Subject: [PATCH 05/24] feat: start from ui --- server/src/db/ChannelDB.ts | 8 ++---- server/src/db/derived_types/Lineup.ts | 5 +--- server/src/db/mediaSourceDB.ts | 8 ++++++ server/src/db/schema/TranscodeConfig.ts | 14 ---------- .../ffmpeg/FfmpegPlaybackParamsCalculator.ts | 6 ++--- .../ffmpeg/builder/state/SubtitlesState.ts | 2 +- server/src/ffmpeg/ffmpeg.ts | 26 ++++++++++++++++--- .../Migration1732969335_AddTranscodeConfig.ts | 6 ----- .../legacy_migration/legacyDbMigration.ts | 5 ---- types/src/Channel.ts | 3 +++ types/src/schemas/channelSchema.ts | 11 +++++--- types/src/schemas/transcodeConfigSchemas.ts | 3 --- .../ChannelPropertiesEditor.tsx | 2 +- 13 files changed, 48 insertions(+), 51 deletions(-) diff --git a/server/src/db/ChannelDB.ts b/server/src/db/ChannelDB.ts index 5aabb20df..e2963e303 100644 --- a/server/src/db/ChannelDB.ts +++ b/server/src/db/ChannelDB.ts @@ -398,9 +398,7 @@ export class ChannelDB { if (isDefined(createReq.subtitles) && createReq.subtitles.enabled) { const db = await this.getFileDb(channel.uuid); await db.update((lineup) => { - lineup.subtitlesConfig = { - state: 'disabled', - }; + lineup.subtitlesConfig.enabled = false; }); } @@ -488,9 +486,7 @@ export class ChannelDB { const db = await this.getFileDb(id); await db.update((lineup) => { if (updateReq.subtitles?.enabled ?? false) { - lineup.subtitlesConfig = { - state: 'disabled', - }; + lineup.subtitlesConfig.enabled = false; } else { delete lineup['subtitlesConfig']; } diff --git a/server/src/db/derived_types/Lineup.ts b/server/src/db/derived_types/Lineup.ts index ea5365916..c61e77bfc 100644 --- a/server/src/db/derived_types/Lineup.ts +++ b/server/src/db/derived_types/Lineup.ts @@ -80,10 +80,7 @@ export const OnDemandChannelConfigSchema = z.object({ export type OnDemandChannelConfig = z.infer; export const SubtitlesConfigSchema = z.object({ - enabled: z - .union([z.literal('disabled'), z.literal('enabled')]) - .default('disabled') - .catch('disabled'), + enabled: z.boolean().default(false).catch(false), state: z .union([z.literal('paused'), z.literal('playing')]) .default('paused') diff --git a/server/src/db/mediaSourceDB.ts b/server/src/db/mediaSourceDB.ts index 3ab44631a..4072aaf6e 100644 --- a/server/src/db/mediaSourceDB.ts +++ b/server/src/db/mediaSourceDB.ts @@ -204,6 +204,14 @@ export class MediaSourceDB { return newServer?.uuid; } + async getMediaSourceById(id: string): Promise { + return getDatabase() + .selectFrom('mediaSource') + .selectAll() + .where('mediaSource.uuid', '=', id) + .executeTakeFirst(); + } + // private async removeDanglingPrograms(mediaSource: MediaSource) { // const knownProgramIds = await directDbAccess() // .selectFrom('programExternalId as p1') diff --git a/server/src/db/schema/TranscodeConfig.ts b/server/src/db/schema/TranscodeConfig.ts index d96ffcedf..61594f8d2 100644 --- a/server/src/db/schema/TranscodeConfig.ts +++ b/server/src/db/schema/TranscodeConfig.ts @@ -70,17 +70,6 @@ export const TranscodeAudioOutputFormat = { Mp3: 'mp3' as const, } as const; -export const TranscodeSubtitlesOutputFormats = ['ass', 'srt'] as const; - -export type TranscodeSubtitlesOutputFormat = TupleToUnion< - typeof TranscodeSubtitlesOutputFormats ->; - -export const TranscodeSubtitlesOutputFormat = { - Ass: 'ass' as const, - Srt: 'srt' as const, -} as const; - export const ErrorScreenTypes = [ 'static', 'pic', @@ -110,7 +99,6 @@ export const TranscodeConfigColumns: (keyof TrannscodeConfigTable)[] = [ 'name', 'normalizeFrameRate', 'resolution', - 'subtitlesFormat', 'threadCount', 'uuid', 'vaapiDevice', @@ -151,8 +139,6 @@ export interface TrannscodeConfigTable extends WithUuid { audioSampleRate: number; audioVolumePercent: Generated; // Default 100 - subtitlesFormat: TranscodeSubtitlesOutputFormat; - normalizeFrameRate: Generated; // Boolean deinterlaceVideo: Generated; // Boolean disableChannelOverlay: Generated; // Boolean diff --git a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts index 556adc682..a080910b1 100644 --- a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts +++ b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts @@ -1,7 +1,6 @@ import { TranscodeAudioOutputFormat, TranscodeConfig, - TranscodeSubtitlesOutputFormat, } from '@/db/schema/TranscodeConfig.ts'; import { ChannelStreamMode } from '@/db/schema/base.ts'; import { StreamDetails, VideoStreamDetails } from '@/stream/types.ts'; @@ -26,7 +25,6 @@ export class FfmpegPlaybackParamsCalculator { return { hwAccel: HardwareAccelerationMode.None, audioFormat: TranscodeAudioOutputFormat.Copy, - subtitlesFormat: TranscodeSubtitlesOutputFormat.Srt, videoFormat: 'copy', // Should be included in DB options deinterlace: false, } satisfies FfmpegPlaybackParams; @@ -40,7 +38,7 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, - subtitlesFormat: this.transcodeConfig.subtitlesFormat, + // subtitlesFormat: this.transcodeConfig.subtitlesFormat, videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, @@ -113,7 +111,7 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, - subtitlesFormat: this.transcodeConfig.subtitlesFormat, + // subtitlesFormat: this.transcodeConfig.subtitlesFormat, videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, diff --git a/server/src/ffmpeg/builder/state/SubtitlesState.ts b/server/src/ffmpeg/builder/state/SubtitlesState.ts index e2fce2127..7b6f54304 100644 --- a/server/src/ffmpeg/builder/state/SubtitlesState.ts +++ b/server/src/ffmpeg/builder/state/SubtitlesState.ts @@ -11,7 +11,7 @@ const DefaultSubtitlesState: SubtitlesState = { subtitlesEncoder: 'srt', subtitlesLanguage: 'und', // 'und' for undefined subtitlesCodec: null, - subtitlesFormat: null, + subtitlesFormat: 'srt', subtitlesDelay: null, subtitlesDuration: null, subtitlesDefault: false, diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index 0cf2ec1fa..cbdfb2432 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -11,7 +11,13 @@ 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 { + ChannelStreamMode, + FfmpegSettings, + Resolution, + Watermark, + Subtitles, +} from '@tunarr/types'; import { NvidiaHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts'; import dayjs from 'dayjs'; @@ -146,11 +152,12 @@ export type StreamOptions = { startTime: Duration; duration: Duration; watermark?: Watermark; + subtitles?: Subtitles; realtime?: boolean; // = true, extraInputHeaders?: Record; outputFormat: OutputFormat; ptsOffset?: number; - streamMode?: ChannelStreamMode + streamMode?: ChannelStreamMode; }; export type StreamSessionOptions = StreamOptions & { @@ -226,6 +233,8 @@ export class FFMPEG implements IFFMPEG { streamUrl, ]; + console.log('FFmpeg Arguments:', ffmpegArgs.join(' ')); + // Workaround until new pipeline is in place... const scThreshold = this.transcodeConfig.videoFormat.includes('mpeg2') ? '1000000000' @@ -340,6 +349,7 @@ export class FFMPEG implements IFFMPEG { streamDetails, startTime, duration, + subtitles: enableSubs, watermark: enableIcon, realtime = true, outputFormat, @@ -352,6 +362,7 @@ export class FFMPEG implements IFFMPEG { startTime, duration, realtime, + enableSubs, enableIcon, outputFormat, ptsOffset ?? null, @@ -396,6 +407,7 @@ export class FFMPEG implements IFFMPEG { undefined, streamStats.duration!, true, + /*subtitles=*/ undefined, /*watermark=*/ undefined, outputFormat, null, @@ -420,6 +432,7 @@ export class FFMPEG implements IFFMPEG { duration, true, undefined, + undefined, outputFormat, null, ); @@ -431,6 +444,7 @@ export class FFMPEG implements IFFMPEG { startTime: Maybe, duration: Duration, realtime: boolean, + subtitles: Maybe, watermark: Maybe, outputFormat: OutputFormat, ptsOffset: Nullable, @@ -457,8 +471,8 @@ export class FFMPEG implements IFFMPEG { const vaapiDevice = isNonEmptyString(this.transcodeConfig.vaapiDevice) ? this.transcodeConfig.vaapiDevice : isLinux() - ? '/dev/dri/renderD128' - : undefined; + ? '/dev/dri/renderD128' + : undefined; ffmpegArgs.push( // Crude workaround for no av1 decoding support ...(videoStream?.codec === 'av1' ? [] : ['-hwaccel', 'vaapi']), @@ -739,6 +753,10 @@ export class FFMPEG implements IFFMPEG { this.ensureResolution = true; } + if (!isNil(subtitles?.url)) { + ffmpegArgs.push(`-i`, `${subtitles.url}`); + } + // Resolution fix: Add scale filter, current stream becomes [siz] const beforeSizeChange = currentVideo; const algo = 'bicubic'; // Scaling up - hardcode bicubic. diff --git a/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts b/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts index 85be8d6b8..5ff0b8c6d 100644 --- a/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts +++ b/server/src/migration/db/Migration1732969335_AddTranscodeConfig.ts @@ -2,7 +2,6 @@ import { getSettings } from '@/db/SettingsDB.ts'; import { NewTranscodeConfig, TranscodeAudioOutputFormats, - TranscodeSubtitlesOutputFormats, TranscodeVideoOutputFormats, } from '@/db/schema/TranscodeConfig.ts'; import { DB } from '@/db/schema/db.ts'; @@ -86,7 +85,6 @@ export default { .addColumn('is_default', 'boolean', (col) => col.notNull().defaultTo(false), ) - .addColumn('subtitles_format', 'text') .execute(); await db.schema @@ -105,9 +103,6 @@ export default { const audioSetting = TranscodeAudioOutputFormats.find( (fmt) => existingFfmpegSettings.audioEncoder === fmt, ); - const subtitlesSetting = TranscodeSubtitlesOutputFormats.find( - (fmt) => existingFfmpegSettings.subtitlesEncoder === fmt, - ); const videoSetting = TranscodeVideoOutputFormats.find( (fmt) => existingFfmpegSettings.videoFormat === fmt, ); @@ -140,7 +135,6 @@ export default { vaapiDevice: existingFfmpegSettings.vaapiDevice, videoBitDepth: 8, isDefault: booleanToNumber(true), - subtitlesFormat: subtitlesSetting ?? 'srt', }; const transcodeConfigId = ( diff --git a/server/src/migration/legacy_migration/legacyDbMigration.ts b/server/src/migration/legacy_migration/legacyDbMigration.ts index 57f7b0334..23623deec 100644 --- a/server/src/migration/legacy_migration/legacyDbMigration.ts +++ b/server/src/migration/legacy_migration/legacyDbMigration.ts @@ -3,7 +3,6 @@ import { NewCachedImage } from '@/db/schema/CachedImage.ts'; import { NewTranscodeConfig, TranscodeAudioOutputFormats, - TranscodeSubtitlesOutputFormats, TranscodeVideoOutputFormats, } from '@/db/schema/TranscodeConfig.ts'; import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.ts'; @@ -498,9 +497,6 @@ export class LegacyDbMigrator { const audioSetting = TranscodeAudioOutputFormats.find( (fmt) => newFfmpegSettings.audioEncoder === fmt, ); - const subtitlesSetting = TranscodeSubtitlesOutputFormats.find( - (fmt) => newFfmpegSettings.subtitlesEncoder === fmt, - ); const videoSetting = TranscodeVideoOutputFormats.find( (fmt) => newFfmpegSettings.videoFormat === fmt, ); @@ -517,7 +513,6 @@ export class LegacyDbMigrator { resolution: JSON.stringify( newFfmpegSettings.targetResolution satisfies Resolution, ), - subtitlesFormat: subtitlesSetting ?? 'srt', threadCount: newFfmpegSettings.numThreads, uuid: v4(), videoBitRate: newFfmpegSettings.videoBitrate, diff --git a/types/src/Channel.ts b/types/src/Channel.ts index 060ef14d8..105cbfb28 100644 --- a/types/src/Channel.ts +++ b/types/src/Channel.ts @@ -6,11 +6,14 @@ import { ChannelTranscodingOptionsSchema, FillerCollectionSchema, SaveChannelRequestSchema, + SubtitlesSchema, WatermarkSchema, type ChannelStreamMode as ChannelStreamModeType, } from './schemas/channelSchema.js'; import { ChannelIconSchema } from './schemas/index.js'; +export type Subtitles = z.infer; + export type Watermark = z.infer; export type FillerCollection = z.infer; diff --git a/types/src/schemas/channelSchema.ts b/types/src/schemas/channelSchema.ts index c65f74dae..37e9c0f76 100644 --- a/types/src/schemas/channelSchema.ts +++ b/types/src/schemas/channelSchema.ts @@ -7,6 +7,13 @@ import { } from './programmingSchema.js'; import { ChannelIconSchema } from './utilSchemas.js'; +export const SubtitlesSchema = z.object({ + url: z.string().optional(), + enabled: z.boolean(), + position: z.union([z.literal('top'), z.literal('bottom')]).default('bottom'), + opacity: z.number().min(0).max(100).int().optional().catch(100).default(100), +}); + export const WatermarkSchema = z.object({ url: z.string().optional(), enabled: z.boolean(), @@ -98,9 +105,7 @@ export const ChannelSchema = z.object({ stealth: z.boolean(), transcoding: ChannelTranscodingOptionsSchema.optional(), watermark: WatermarkSchema.optional(), - subtitles: z.object({ - enabled: z.boolean(), - }), + subtitles: SubtitlesSchema.optional(), onDemand: z.object({ enabled: z.boolean(), }), diff --git a/types/src/schemas/transcodeConfigSchemas.ts b/types/src/schemas/transcodeConfigSchemas.ts index 1ee53f80d..f0faab82c 100644 --- a/types/src/schemas/transcodeConfigSchemas.ts +++ b/types/src/schemas/transcodeConfigSchemas.ts @@ -37,8 +37,6 @@ export type SupportedTranscodeAudioOutputFormats = TupleToUnion< typeof SupportedTranscodeAudioOutputFormats >; -export const SupportedTranscodeSubtitlesOutputFormats = ['ass', 'srt'] as const; - export const TranscodeConfigSchema = z.object({ id: z.string(), name: z.string(), @@ -59,7 +57,6 @@ export const TranscodeConfigSchema = z.object({ audioBufferSize: z.number(), audioSampleRate: z.number(), audioVolumePercent: z.number().default(100), - subtitlesFormat: z.enum(SupportedTranscodeSubtitlesOutputFormats), normalizeFrameRate: z.boolean(), deinterlaceVideo: z.boolean(), disableChannelOverlay: z.boolean(), diff --git a/web/src/components/channel_config/ChannelPropertiesEditor.tsx b/web/src/components/channel_config/ChannelPropertiesEditor.tsx index eec0be741..47941d6f0 100644 --- a/web/src/components/channel_config/ChannelPropertiesEditor.tsx +++ b/web/src/components/channel_config/ChannelPropertiesEditor.tsx @@ -234,7 +234,7 @@ export function ChannelPropertiesEditor({ isNew }: Props) { Enable subtitles for channel media where available.
- NOTE: Only external subtitle files are are + NOTE: Only external subtitle files are supported at this time.
Date: Fri, 10 Jan 2025 10:12:19 -0500 Subject: [PATCH 06/24] feat: remove cruft --- server/src/db/TranscodeConfigDB.ts | 1 - server/src/db/converters/channelConverters.ts | 3 -- server/src/db/derived_types/Lineup.ts | 7 ++-- .../ffmpeg/FfmpegPlaybackParamsCalculator.ts | 10 +++-- server/src/ffmpeg/FfmpegStreamFactory.ts | 36 ----------------- .../builder/FfmpegCommandGenerator.test.ts | 21 +--------- .../ffmpeg/builder/FfmpegCommandGenerator.ts | 11 ----- server/src/ffmpeg/builder/MediaStream.ts | 18 --------- server/src/ffmpeg/builder/constants.ts | 5 --- .../src/ffmpeg/builder/encoder/BaseEncoder.ts | 6 --- .../src/ffmpeg/builder/input/InputSource.ts | 2 +- .../builder/input/SubtitlesInputSource.ts | 22 ---------- .../builder/options/SubtitleOutputOptions.ts | 4 -- .../builder/pipeline/BasePipelineBuilder.ts | 40 +------------------ .../src/ffmpeg/builder/pipeline/Pipeline.ts | 1 - .../pipeline/PipelineBuilderFactory.ts | 14 ------- .../ffmpeg/builder/pipeline/PipelineInputs.ts | 2 - .../hardware/NvidiaPipelineBuilder.ts | 3 -- .../pipeline/hardware/VaapiPipelineBuilder.ts | 3 -- .../hardware/VideoToolboxPipelineBuilder.ts | 3 -- server/src/ffmpeg/ffmpeg.ts | 38 +++++++++++++++++- .../legacy_migration/legacyDbMigration.ts | 1 - .../stream/jellyfin/JellyfinProgramStream.ts | 9 +++++ .../stream/jellyfin/JellyfinStreamDetails.ts | 28 ------------- server/src/stream/types.ts | 5 --- types/src/schemas/channelSchema.ts | 5 +-- types/src/schemas/settingsSchemas.ts | 1 - 27 files changed, 60 insertions(+), 239 deletions(-) delete mode 100644 server/src/ffmpeg/builder/input/SubtitlesInputSource.ts delete mode 100644 server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts diff --git a/server/src/db/TranscodeConfigDB.ts b/server/src/db/TranscodeConfigDB.ts index 62de58331..94b031631 100644 --- a/server/src/db/TranscodeConfigDB.ts +++ b/server/src/db/TranscodeConfigDB.ts @@ -203,7 +203,6 @@ export class TranscodeConfigDB { hardwareAccelerationMode: 'none', normalizeFrameRate: booleanToNumber(false), deinterlaceVideo: booleanToNumber(true), - subtitlesFormat: 'srt', videoBitRate: 3500, videoBufferSize: 3500 * 2, videoFormat: 'h264', diff --git a/server/src/db/converters/channelConverters.ts b/server/src/db/converters/channelConverters.ts index 9f7572445..0ff675b1b 100644 --- a/server/src/db/converters/channelConverters.ts +++ b/server/src/db/converters/channelConverters.ts @@ -33,9 +33,6 @@ export const dbChannelToApiChannel = ({ transcoding: nilToUndefined(channel.transcoding), duration: channel.duration, stealth: channel.stealth === 1, - subtitles: { - enabled: isDefined(lineup.subtitlesConfig), - }, onDemand: { enabled: isDefined(lineup.onDemandConfig), }, diff --git a/server/src/db/derived_types/Lineup.ts b/server/src/db/derived_types/Lineup.ts index c61e77bfc..63ae94f7a 100644 --- a/server/src/db/derived_types/Lineup.ts +++ b/server/src/db/derived_types/Lineup.ts @@ -79,6 +79,7 @@ export const OnDemandChannelConfigSchema = z.object({ export type OnDemandChannelConfig = z.infer; +<<<<<<< HEAD export const SubtitlesConfigSchema = z.object({ enabled: z.boolean().default(false).catch(false), state: z @@ -90,6 +91,8 @@ export const SubtitlesConfigSchema = z.object({ export type SubtitlesConfig = z.infer; +======= +>>>>>>> 2dcb4235 (feat: remove cruft) export const CurrentLineupSchemaVersion = 1; export const LineupSchema = z.object({ @@ -148,10 +151,6 @@ export const LineupSchema = z.object({ // OnDemand configuration for this channel. If empty, the channel // is not configured as on-demand. onDemandConfig: OnDemandChannelConfigSchema.optional(), - - // Subtitle configuration for this channel. If empty, the channel - // has subtitles turned off. - subtitlesConfig: SubtitlesConfigSchema.optional(), }); export type Lineup = z.infer; diff --git a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts index a080910b1..9372c8555 100644 --- a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts +++ b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts @@ -38,7 +38,10 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, +<<<<<<< HEAD // subtitlesFormat: this.transcodeConfig.subtitlesFormat, +======= +>>>>>>> 2dcb4235 (feat: remove cruft) videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, @@ -111,7 +114,10 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, +<<<<<<< HEAD // subtitlesFormat: this.transcodeConfig.subtitlesFormat, +======= +>>>>>>> 2dcb4235 (feat: remove cruft) videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, @@ -161,10 +167,6 @@ export type FfmpegPlaybackParams = { audioChannels?: number; audioSampleRate?: number; audioDuration?: number; - - //subtitle details - subtitlesFormat: string; - subtitlesLanguage?: string; }; function needsToScale( diff --git a/server/src/ffmpeg/FfmpegStreamFactory.ts b/server/src/ffmpeg/FfmpegStreamFactory.ts index 25c0c4eac..8f1dfedaf 100644 --- a/server/src/ffmpeg/FfmpegStreamFactory.ts +++ b/server/src/ffmpeg/FfmpegStreamFactory.ts @@ -15,7 +15,6 @@ import { FfmpegTranscodeSession } from './FfmpegTrancodeSession.ts'; import { AudioStream, StillImageStream, - SubtitlesStream, VideoStream, } from './builder/MediaStream.ts'; import { @@ -37,14 +36,12 @@ import { } from './builder/input/AudioInputSource.ts'; import { ConcatInputSource } from './builder/input/ConcatInputSource.ts'; import { LavfiVideoInputSource } from './builder/input/LavfiVideoInputSource.ts'; -import { SubtitlesInputSource } from './builder/input/SubtitlesInputSource.ts'; import { VideoInputSource } from './builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from './builder/input/WatermarkInputSource.ts'; import { PipelineBuilderFactory } from './builder/pipeline/PipelineBuilderFactory.ts'; import { AudioState } from './builder/state/AudioState.ts'; import { FfmpegState } from './builder/state/FfmpegState.ts'; import { FrameState } from './builder/state/FrameState.ts'; -import { SubtitlesState } from './builder/state/SubtitlesState.ts'; import { FrameSize } from './builder/types.ts'; import { ConcatOptions, StreamSessionOptions } from './ffmpeg.ts'; import { HlsWrapperOptions, IFFMPEG } from './ffmpegBase.ts'; @@ -244,7 +241,6 @@ export class FfmpegStreamFactory extends IFFMPEG { ...pipeline.inputs, videoInput: null, audioInput: null, - subtitlesInput: null, }); return new FfmpegTranscodeSession( @@ -371,34 +367,6 @@ export class FfmpegStreamFactory extends IFFMPEG { }); } - const subtitlesState = SubtitlesState.create({ - subtitlesEncoder: playbackParams.subtitlesFormat, - subtitlesLanguage: playbackParams.subtitlesLanguage, - }); - - let subtitlesInput: Nullable = null; - if (isDefined(streamDetails.subtitlesDetails)) { - const subtitlesStream = - find(streamDetails.subtitlesDetails, { selected: true }) ?? - find(streamDetails.subtitlesDetails, { default: true }) ?? - first(streamDetails.subtitlesDetails); - const subtitleStreamIndex = isNonEmptyString(subtitlesStream.index) - ? parseInt(subtitlesStream.index) - : 0; - - subtitlesInput = new SubtitlesInputSource( - streamSource, - [ - SubtitlesStream.create({ - index: isNaN(subtitleStreamIndex) ? 0 : subtitleStreamIndex, - codec: subtitlesStream.codec ?? 'unknown', - language: subtitlesStream.language ?? 'und', - }), - ], - subtitlesState, - ); - } - let watermarkSource: Nullable = null; if (streamMode !== ChannelStreamModes.HlsDirect && watermark?.enabled) { const watermarkUrl = watermark.url ?? makeLocalUrl('/images/tunarr.png'); @@ -420,7 +388,6 @@ export class FfmpegStreamFactory extends IFFMPEG { .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) .setVideoInputSource(videoInput) .setAudioInputSource(audioInput) - .setSubtitlesInputSource(subtitlesInput) .setWatermarkInputSource(watermarkSource) .build(); @@ -664,14 +631,11 @@ export class FfmpegStreamFactory extends IFFMPEG { break; } - let subtitlesInput: SubtitlesInputSource; - const builder = await new PipelineBuilderFactory() .builder(this.transcodeConfig) .setHardwareAccelerationMode(this.ffmpegSettings.hardwareAccelerationMode) .setVideoInputSource(offlineInput) .setAudioInputSource(audioInput) - .setSubtitlesInputSource(subtitlesInput) .build(); const pipeline = builder.build( diff --git a/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts b/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts index d5aba846b..05ed93b0a 100644 --- a/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts +++ b/server/src/ffmpeg/builder/FfmpegCommandGenerator.test.ts @@ -2,12 +2,7 @@ import { bootstrapTunarr } from '@/bootstrap.ts'; import { setGlobalOptions } from '@/globals.ts'; import tmp from 'tmp'; import { FfmpegCommandGenerator } from './FfmpegCommandGenerator.ts'; -import { - AudioStream, - StillImageStream, - SubtitlesStream, - VideoStream, -} from './MediaStream.ts'; +import { AudioStream, StillImageStream, VideoStream } from './MediaStream.ts'; import { VideoFormats } from './constants.ts'; import { PixelFormat, @@ -15,12 +10,10 @@ import { PixelFormatYuv420P10Le, } from './format/PixelFormat.ts'; import { AudioInputSource } from './input/AudioInputSource.ts'; -import { SubtitlesInputSource } from './input/SubtitlesInputSource.ts'; import { VideoInputSource } from './input/VideoInputSource.ts'; import { WatermarkInputSource } from './input/WatermarkInputSource.ts'; import { PipelineBuilderFactory } from './pipeline/PipelineBuilderFactory.ts'; import { AudioState } from './state/AudioState.ts'; -import { SubtitlesState } from './state/SubtitlesState.ts'; import { FfmpegState } from './state/FfmpegState.ts'; import { FrameState } from './state/FrameState.ts'; import { FrameSize } from './types.ts'; @@ -78,10 +71,6 @@ describe('FfmpegCommandGenerator', () => { audioDuration: 11_000, }); - const subtitleState = SubtitlesState.create({ - subtitlesEncoder: 'srt', - }); - const target = FrameSize.withDimensions(1280, 720); const desiredState = new FrameState({ @@ -109,12 +98,6 @@ describe('FfmpegCommandGenerator', () => { audioState, ); - const subtitlesInputFile = new SubtitlesInputSource( - videoInputFile.path, - [SubtitlesStream.create({ index: 1, codec: 'srt' })], - subtitleState, - ); - const watermarkInputFile = new WatermarkInputSource( 'http://localhost:8000/images/tunarr.png', StillImageStream.create({ @@ -137,7 +120,6 @@ describe('FfmpegCommandGenerator', () => { .setHardwareAccelerationMode('vaapi') .setVideoInputSource(videoInputFile) .setAudioInputSource(audioInputFile) - .setSubtitlesInputSource(subtitlesInputFile) .setWatermarkInputSource(watermarkInputFile) .build(); @@ -152,7 +134,6 @@ describe('FfmpegCommandGenerator', () => { const result = generator.generateArgs( videoInputFile, audioInputFile, - subtitlesInputFile, watermarkInputFile, steps, ); diff --git a/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts b/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts index 1c8b4b24f..05c0e9c0a 100644 --- a/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts +++ b/server/src/ffmpeg/builder/FfmpegCommandGenerator.ts @@ -6,7 +6,6 @@ import { BaseEncoder } from './encoder/BaseEncoder.ts'; import { ComplexFilter } from './filter/ComplexFilter.ts'; import { AudioInputSource } from './input/AudioInputSource.ts'; import { ConcatInputSource } from './input/ConcatInputSource.ts'; -import { SubtitlesInputSource } from './input/SubtitlesInputSource.ts'; import { VideoInputSource } from './input/VideoInputSource.ts'; import { WatermarkInputSource } from './input/WatermarkInputSource.ts'; import { @@ -33,7 +32,6 @@ export class FfmpegCommandGenerator { generateArgs( videoInputSource: Nullable, audioInputSource: Nullable, - subtitlesInputSource: Nullable, watermarkInputSource: Nullable, concatInputSource: Nullable, steps: PipelineStep[], @@ -62,15 +60,6 @@ export class FfmpegCommandGenerator { ); } - if (subtitlesInputSource && !includedPaths.has(subtitlesInputSource.path)) { - includedPaths.add(subtitlesInputSource.path); - args.push( - ...subtitlesInputSource.getInputOptions(), - '-i', - subtitlesInputSource.path, - ); - } - if (watermarkInputSource && !includedPaths.has(watermarkInputSource.path)) { includedPaths.add(watermarkInputSource.path); args.push( diff --git a/server/src/ffmpeg/builder/MediaStream.ts b/server/src/ffmpeg/builder/MediaStream.ts index 182676cc8..f4c7393ae 100644 --- a/server/src/ffmpeg/builder/MediaStream.ts +++ b/server/src/ffmpeg/builder/MediaStream.ts @@ -22,7 +22,6 @@ type MediaStreamFields = Omit< // semantics with class construction, but still enabling us // to have hierarchies, methods, etc. type AudioStreamFields = MediaStreamFields; -type SubtitlesStreamFields = MediaStreamFields; type VideoStreamFields = Omit, 'isAnamorphic'>; export class AudioStream implements MediaStream { @@ -42,23 +41,6 @@ export class AudioStream implements MediaStream { } } -export class SubtitlesStream implements MediaStream { - readonly kind: StreamKind = 'subtitles'; - index: number; - codec: string; - language?: string; - - private constructor(fields: SubtitlesStreamFields) { - this.index = fields.index; - this.codec = fields.codec; - this.language = fields.language; - } - - static create(fields: SubtitlesStreamFields) { - return new SubtitlesStream(fields); - } -} - export type VideoInputKind = 'video' | 'stillimage' | 'filter'; export class VideoStream implements MediaStream { diff --git a/server/src/ffmpeg/builder/constants.ts b/server/src/ffmpeg/builder/constants.ts index 8488eb27d..f878c14e0 100644 --- a/server/src/ffmpeg/builder/constants.ts +++ b/server/src/ffmpeg/builder/constants.ts @@ -25,11 +25,6 @@ export const AudioFormats = { Flac: 'flac', } as const; -export const SubtitlesFormats = { - Srt: 'srt', - AdvancedSubStationAlpha: 'ass', -} as const; - export const OutputLocation = { Stdout: 'stdout', } as const; diff --git a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts index ced772964..3a745e1d0 100644 --- a/server/src/ffmpeg/builder/encoder/BaseEncoder.ts +++ b/server/src/ffmpeg/builder/encoder/BaseEncoder.ts @@ -46,12 +46,6 @@ export class AudioEncoder extends BaseEncoder { } } -export class SubtitlesEncoder extends BaseEncoder { - constructor(encoder: string) { - super(encoder, 'subtitles'); - } -} - export abstract class VideoEncoder extends BaseEncoder { protected abstract videoFormat: string; diff --git a/server/src/ffmpeg/builder/input/InputSource.ts b/server/src/ffmpeg/builder/input/InputSource.ts index 51459cb76..3a666f160 100644 --- a/server/src/ffmpeg/builder/input/InputSource.ts +++ b/server/src/ffmpeg/builder/input/InputSource.ts @@ -10,7 +10,7 @@ import { } from '../../../stream/types.ts'; import { VideoInputSource } from './VideoInputSource.ts'; -export type InputSourceType = 'video' | 'audio' | 'subtitles'; +export type InputSourceType = 'video' | 'audio'; export type InputSourceProtocol = 'file' | 'http' | 'filter'; export type InputSourceContinuity = 'discrete' | 'infinite'; diff --git a/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts b/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts deleted file mode 100644 index 501b79d1a..000000000 --- a/server/src/ffmpeg/builder/input/SubtitlesInputSource.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SubtitlesStream } from '@/ffmpeg/builder/MediaStream.ts'; -import { SubtitlesState } from '../state/SubtitlesState.ts'; -import { - InputSource, - InputSourceContinuity, - StreamSource, -} from './InputSource.ts'; - -export class SubtitlesInputSource< - StreamType extends SubtitlesStream = SubtitlesStream, -> extends InputSource { - readonly type = 'subtitles'; - - constructor( - source: StreamSource, - public streams: StreamType[], - public desiredState: SubtitlesState, - continuity: InputSourceContinuity = 'discrete', - ) { - super(source, continuity); - } -} diff --git a/server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts b/server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts deleted file mode 100644 index 2f189b57a..000000000 --- a/server/src/ffmpeg/builder/options/SubtitleOutputOptions.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { makeConstantOutputOption } from './OutputOption.ts'; - -export const SubtitleFormatOutputOption = (subtitleFormat: string) => - makeConstantOutputOption([`${subtitleFormat}`]); diff --git a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts index e7dee5474..2b92eb138 100644 --- a/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/BasePipelineBuilder.ts @@ -1,14 +1,9 @@ -import { - AudioStream, - SubtitlesStream, - VideoStream, -} from '@/ffmpeg/builder/MediaStream.ts'; +import { AudioStream, VideoStream } from '@/ffmpeg/builder/MediaStream.ts'; import { FfmpegCapabilities } from '@/ffmpeg/builder/capabilities/FfmpegCapabilities.ts'; import { Decoder } from '@/ffmpeg/builder/decoder/Decoder.ts'; import { DecoderFactory } from '@/ffmpeg/builder/decoder/DecoderFactory.ts'; import { AudioEncoder, - SubtitlesEncoder, VideoEncoder, } from '@/ffmpeg/builder/encoder/BaseEncoder.ts'; import { Encoder } from '@/ffmpeg/builder/encoder/Encoder.ts'; @@ -36,7 +31,6 @@ import { UserAgentInputOption } from '@/ffmpeg/builder/options/input/UserAgentIn import { AudioState } from '@/ffmpeg/builder/state/AudioState.ts'; import { FfmpegState } from '@/ffmpeg/builder/state/FfmpegState.ts'; import { FrameState } from '@/ffmpeg/builder/state/FrameState.ts'; -import { SubtitlesState } from '@/ffmpeg/builder/state/SubtitlesState.ts'; import { FrameDataLocation, HardwareAccelerationMode, @@ -107,7 +101,6 @@ import { } from '../options/OutputOption.ts'; import { Pipeline } from './Pipeline.ts'; import { PipelineBuilder } from './PipelineBuilder.ts'; -import { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts'; // Args passed to each setter -- we use an object here so we // 1. can deconstruct args in each implementor to use only what we need @@ -131,11 +124,9 @@ export type PipelineAudioFunctionArgs = { export type PipelineBuilderContext = { videoStream?: VideoStream; audioStream?: AudioStream; - subtitlesStream?: SubtitlesStream; ffmpegState: FfmpegState; desiredState: FrameState; desiredAudioState?: AudioState; - desiredSubtitlesState?: SubtitlesState; pipelineSteps: PipelineStep[]; filterChain: FilterChain; decoder: Nullable; @@ -155,10 +146,6 @@ export type PipelineBuilderContextWithAudio = MarkRequired< PipelineBuilderContext, 'audioStream' | 'desiredAudioState' >; -export type PipelineBuilderContextWithSubtitles = MarkRequired< - PipelineBuilderContext, - 'subtitlesStream' | 'desiredSubtitlesState' ->; export function isVideoPipelineContext( context: PipelineBuilderContext, @@ -174,15 +161,6 @@ export function isAudioPipelineContext( ); } -export function isSubtitlesPipelineContext( - context: PipelineBuilderContext, -): context is PipelineBuilderContextWithSubtitles { - return ( - !isUndefined(context.subtitlesStream) && - !isUndefined(context.desiredSubtitlesState) - ); -} - export abstract class BasePipelineBuilder implements PipelineBuilder { protected logger: Logger = LoggerFactory.child({ caller: import.meta, @@ -194,7 +172,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { constructor( protected nullableVideoInputSource: Nullable, private audioInputSource: Nullable, - private subtitlesInputSource: Nullable, protected watermarkInputSource: Nullable, protected concatInputSource: Nullable, protected ffmpegCapabilities: FfmpegCapabilities, @@ -253,7 +230,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(pipelineSteps, { videoInput: null, audioInput: null, - subtitlesInput: null, concatInput: input, watermarkInput: null, }); @@ -294,7 +270,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(pipelineSteps, { videoInput: null, audioInput: null, - subtitlesInput: null, concatInput: input, watermarkInput: null, }); @@ -304,7 +279,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { this.context = { videoStream: first(this.videoInputSource.streams), audioStream: first(this.audioInputSource?.streams), - subtitlesStream: first(this.subtitlesInputSource?.streams), ffmpegState, desiredState, desiredAudioState: this.audioInputSource?.desiredState, @@ -446,7 +420,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { new ComplexFilter( this.videoInputSource, this.audioInputSource, - this.subtitlesInputSource, this.watermarkInputSource, this.context.filterChain, ), @@ -459,7 +432,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { return new Pipeline(this.pipelineSteps, { videoInput: this.videoInputSource, audioInput: this.audioInputSource, - subtitlesInput: this.subtitlesInputSource, watermarkInput: this.watermarkInputSource, concatInput: this.concatInputSource, }); @@ -564,16 +536,6 @@ export abstract class BasePipelineBuilder implements PipelineBuilder { } } - protected buildSubtitlePipeline() { - if (!isSubtitlesPipelineContext(this.context)) { - return; - } - const encoder = new SubtitlesEncoder( - this.context.desiredSubtitlesState.subtitlesEncoder, - ); - this.pipelineSteps.push(encoder); - } - protected abstract setupVideoFilters(): void; protected setupEncoder(currentState: FrameState): Nullable { diff --git a/server/src/ffmpeg/builder/pipeline/Pipeline.ts b/server/src/ffmpeg/builder/pipeline/Pipeline.ts index b64be6c49..258c930d2 100644 --- a/server/src/ffmpeg/builder/pipeline/Pipeline.ts +++ b/server/src/ffmpeg/builder/pipeline/Pipeline.ts @@ -18,7 +18,6 @@ export class Pipeline { return this.#commandGenerator.generateArgs( this.inputs.videoInput, this.inputs.audioInput, - this.inputs.subtitlesInput, this.inputs.watermarkInput, this.inputs.concatInput, this.steps, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts index 15a22399e..2a44aa586 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineBuilderFactory.ts @@ -16,7 +16,6 @@ import { QsvPipelineBuilder } from './hardware/QsvPipelineBuilder.ts'; import { VaapiPipelineBuilder } from './hardware/VaapiPipelineBuilder.ts'; import { VideoToolboxPipelineBuilder } from './hardware/VideoToolboxPipelineBuilder.ts'; import { SoftwarePipelineBuilder } from './software/SoftwarePipelineBuilder.ts'; -import { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts'; export class PipelineBuilderFactory { constructor(private settingsDB: SettingsDB = getSettings()) {} @@ -32,7 +31,6 @@ export class PipelineBuilderFactory { class PipelineBuilderFactory$Builder { private videoInputSource: Nullable = null; private audioInputSource: Nullable = null; - private subtitlesInputSource: Nullable = null; private concatInputSource: Nullable = null; private watermarkInputSource: Nullable = null; private hardwareAccelerationMode: HardwareAccelerationMode = 'none'; @@ -56,13 +54,6 @@ class PipelineBuilderFactory$Builder { return this; } - setSubtitlesInputSource( - subtitleInputSource: Nullable, - ): PipelineBuilderFactory$Builder { - this.subtitlesInputSource = subtitleInputSource; - return this; - } - setConcatInputSource(concatInputSource: ConcatInputSource): this { this.concatInputSource = concatInputSource; return this; @@ -102,7 +93,6 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, - this.subtitlesInputSource, this.concatInputSource, this.watermarkInputSource, ); @@ -110,7 +100,6 @@ class PipelineBuilderFactory$Builder { return new QsvPipelineBuilder( this.videoInputSource, this.audioInputSource, - this.subtitlesInputSource, this.watermarkInputSource, this.concatInputSource, binaryCapabilities, @@ -121,7 +110,6 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, - this.subtitlesInputSource, this.watermarkInputSource, this.concatInputSource, ); @@ -131,7 +119,6 @@ class PipelineBuilderFactory$Builder { binaryCapabilities, this.videoInputSource, this.audioInputSource, - this.subtitlesInputSource, this.concatInputSource, this.watermarkInputSource, ); @@ -139,7 +126,6 @@ class PipelineBuilderFactory$Builder { return new SoftwarePipelineBuilder( this.videoInputSource, this.audioInputSource, - this.subtitlesInputSource, this.watermarkInputSource, this.concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts index a57743661..e458d289e 100644 --- a/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts +++ b/server/src/ffmpeg/builder/pipeline/PipelineInputs.ts @@ -1,6 +1,5 @@ import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { Nullable } from '@/types/util.ts'; @@ -8,7 +7,6 @@ import { Nullable } from '@/types/util.ts'; export type PipelineInputs = { videoInput: Nullable; audioInput: Nullable; - subtitlesInput: Nullable; watermarkInput: Nullable; concatInput: Nullable; }; diff --git a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts index 6b9be9c9e..0e08cffe9 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/NvidiaPipelineBuilder.ts @@ -21,7 +21,6 @@ import { YadifCudaFilter } from '@/ffmpeg/builder/filter/nvidia/YadifCudaFilter. import { OverlayWatermarkFilter } from '@/ffmpeg/builder/filter/watermark/OverlayWatermarkFilter.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { PixelFormatOutputOption } from '@/ffmpeg/builder/options/OutputOption.ts'; @@ -54,14 +53,12 @@ export class NvidiaPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, - subtitlesInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, ) { super( videoInputFile, audioInputFile, - subtitlesInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts index 5579ef3a3..eee47f79b 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VaapiPipelineBuilder.ts @@ -18,7 +18,6 @@ import { WatermarkOpacityFilter } from '@/ffmpeg/builder/filter/watermark/Waterm import { WatermarkScaleFilter } from '@/ffmpeg/builder/filter/watermark/WatermarkScaleFilter.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { VaapiDriverEnvironmentVariable } from '@/ffmpeg/builder/options/EnvironmentVariables.ts'; @@ -60,14 +59,12 @@ export class VaapiPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, - subtitlesInputFile: Nullable, watermarkInputSource: Nullable, concatInputSource: Nullable, ) { super( videoInputFile, audioInputFile, - subtitlesInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts index eea922244..98d3e35cd 100644 --- a/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts +++ b/server/src/ffmpeg/builder/pipeline/hardware/VideoToolboxPipelineBuilder.ts @@ -7,7 +7,6 @@ import { FilterOption } from '@/ffmpeg/builder/filter/FilterOption.ts'; import { VideoToolboxHardwareAccelerationOption } from '@/ffmpeg/builder/filter/videotoolbox/VideoToolboxHardwareAccelerationOption.ts'; import { AudioInputSource } from '@/ffmpeg/builder/input/AudioInputSource.ts'; import { ConcatInputSource } from '@/ffmpeg/builder/input/ConcatInputSource.ts'; -import { SubtitlesInputSource } from '../../input/SubtitlesInputSource.ts'; import { VideoInputSource } from '@/ffmpeg/builder/input/VideoInputSource.ts'; import { WatermarkInputSource } from '@/ffmpeg/builder/input/WatermarkInputSource.ts'; import { PixelFormatOutputOption } from '@/ffmpeg/builder/options/OutputOption.ts'; @@ -28,14 +27,12 @@ export class VideoToolboxPipelineBuilder extends SoftwarePipelineBuilder { binaryCapabilities: FfmpegCapabilities, videoInputFile: Nullable, audioInputFile: Nullable, - subtitlesInputFile: Nullable, concatInputSource: Nullable, watermarkInputSource: Nullable, ) { super( videoInputFile, audioInputFile, - subtitlesInputFile, watermarkInputSource, concatInputSource, binaryCapabilities, diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index ce1038d25..1a976a518 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -14,8 +14,13 @@ import { ChannelStreamMode, FfmpegSettings, Resolution, +<<<<<<< HEAD Watermark, Subtitles, +======= + Subtitles, + Watermark, +>>>>>>> 2dcb4235 (feat: remove cruft) } from '@tunarr/types'; import { NvidiaHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts'; @@ -146,12 +151,20 @@ export type StreamOptions = { startTime: Duration; duration: Duration; watermark?: Watermark; +<<<<<<< HEAD subtitles?: Subtitles; +======= + subtitles: Subtitles; +>>>>>>> 2dcb4235 (feat: remove cruft) realtime?: boolean; // = true, extraInputHeaders?: Record; outputFormat: OutputFormat; ptsOffset?: number; +<<<<<<< HEAD streamMode: ChannelStreamMode; +======= + streamMode?: ChannelStreamMode; +>>>>>>> 2dcb4235 (feat: remove cruft) }; export type StreamSessionOptions = StreamOptions & { @@ -345,6 +358,7 @@ export class FFMPEG implements IFFMPEG { duration, subtitles: enableSubs, watermark: enableIcon, + subtitles, realtime = true, outputFormat, ptsOffset, @@ -355,9 +369,14 @@ export class FFMPEG implements IFFMPEG { streamDetails, startTime, duration, +<<<<<<< HEAD realtime, enableSubs, +======= +>>>>>>> 2dcb4235 (feat: remove cruft) enableIcon, + subtitles, + realtime, outputFormat, ptsOffset ?? null, ); @@ -400,9 +419,14 @@ export class FFMPEG implements IFFMPEG { streamStats, undefined, streamStats.duration!, +<<<<<<< HEAD true, /*subtitles=*/ undefined, +======= +>>>>>>> 2dcb4235 (feat: remove cruft) /*watermark=*/ undefined, + /*subtitles=*/ undefined, + true, outputFormat, null, ); @@ -424,9 +448,12 @@ export class FFMPEG implements IFFMPEG { streamStats, undefined, duration, - true, undefined, undefined, +<<<<<<< HEAD +======= + true, +>>>>>>> 2dcb4235 (feat: remove cruft) outputFormat, null, ); @@ -437,9 +464,14 @@ export class FFMPEG implements IFFMPEG { streamStats: Maybe, startTime: Maybe, duration: Duration, +<<<<<<< HEAD realtime: boolean, subtitles: Maybe, +======= +>>>>>>> 2dcb4235 (feat: remove cruft) watermark: Maybe, + subtitles: Maybe, + realtime: boolean, outputFormat: OutputFormat, ptsOffset: Nullable, ): Promise> { @@ -738,6 +770,10 @@ export class FFMPEG implements IFFMPEG { iH = iH!; } + if (subtitles?.enabled && !isNil(subtitles?.path)) { + ffmpegArgs.push(`-vf`, `${subtitles.path}`); + } + if (doOverlay && !isNil(watermark?.url)) { if (watermark.animated) { ffmpegArgs.push('-ignore_loop', '0', '-stream_loop', '-1'); diff --git a/server/src/migration/legacy_migration/legacyDbMigration.ts b/server/src/migration/legacy_migration/legacyDbMigration.ts index 23623deec..5ba4ea07c 100644 --- a/server/src/migration/legacy_migration/legacyDbMigration.ts +++ b/server/src/migration/legacy_migration/legacyDbMigration.ts @@ -444,7 +444,6 @@ export class LegacyDbMigrator { videoFormat, hardwareAccelerationMode: hwAccel, audioEncoder: ffmpegSettings['audioEncoder'] as string, - subtitlesEncoder: ffmpegSettings['subtitlesEncoder'] as string, targetResolution: tryParseResolution( ffmpegSettings['targetResolution'] as string, diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index ff0e56626..d69c8178c 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -99,6 +99,14 @@ export class JellyfinProgramStream extends ProgramStream { : undefined; } + const sourcePath = stream.streamSource.path; + let subtitlesPath: Nullable = null; + if (sourcePath.replace(/\.[^/.]+$/, '.srt')) { + subtitlesPath = sourcePath.replace(/\.[^/.]+$/, '.srt'); + } else { + this.logger.warn(`No subtitle file found at ${subtitlesPath}`); + } + const start = dayjs.duration(lineupItem.startOffset ?? 0); const ffmpegOutStream = await this.ffmpeg.createStreamSession({ @@ -110,6 +118,7 @@ export class JellyfinProgramStream extends ProgramStream { ? dayjs.duration(lineupItem.duration) : dayjs.duration(lineupItem.streamDuration ?? lineupItem.duration), watermark, + subtitles: subtitlesPath ? subtitlesPath : undefined, // Pass subtitle file if it exists realtime: this.context.realtime, extraInputHeaders: {}, outputFormat: this.outputFormat, diff --git a/server/src/stream/jellyfin/JellyfinStreamDetails.ts b/server/src/stream/jellyfin/JellyfinStreamDetails.ts index bbd01ead3..d3807442d 100644 --- a/server/src/stream/jellyfin/JellyfinStreamDetails.ts +++ b/server/src/stream/jellyfin/JellyfinStreamDetails.ts @@ -42,7 +42,6 @@ import { ProgramStreamResult, StreamDetails, StreamSource, - SubtitlesStreamDetails, VideoStreamDetails, } from '../types.js'; @@ -285,30 +284,6 @@ export class JellyfinStreamDetails { }, ); - const subtitlesStreamDetails = map( - sortBy( - filter( - firstMediaSource?.MediaStreams, - (stream) => stream.Type === 'Subtitle', - ), - (stream) => [stream.Index ?? 0, !stream.IsDefault], - ), - (subtitlesStream) => { - return { - codec: nullToUndefined(subtitlesStream.Codec), - index: - ifDefined(subtitlesStream.Index, (streamIndex) => { - const index = streamIndex - externalStreamCount; - if (index >= 0) { - return index.toString(); - } - return; - }) ?? undefined, - profile: nullToUndefined(subtitlesStream.Profile), - } satisfies SubtitlesStreamDetails; - }, - ); - if (!videoStreamDetails && isEmpty(audioStreamDetails)) { this.logger.warn( 'Could not find a video nor audio stream for Plex item %s', @@ -326,9 +301,6 @@ export class JellyfinStreamDetails { audioDetails: isEmpty(audioStreamDetails) ? undefined : (audioStreamDetails as NonEmptyArray), - subtitlesDetails: isEmpty(subtitlesStreamDetails) - ? undefined - : (subtitlesStreamDetails as NonEmptyArray), }; if (audioOnly) { diff --git a/server/src/stream/types.ts b/server/src/stream/types.ts index f4d40f68d..3b326901a 100644 --- a/server/src/stream/types.ts +++ b/server/src/stream/types.ts @@ -59,11 +59,6 @@ export type AudioStreamDetails = { export type SubtitlesStreamDetails = { index?: string; - channels?: number; - codec?: string; - bitrate?: number; - profile?: string; - default?: boolean; selected?: boolean; title?: string; language?: string; diff --git a/types/src/schemas/channelSchema.ts b/types/src/schemas/channelSchema.ts index 7387933b5..ae2e8ddb5 100644 --- a/types/src/schemas/channelSchema.ts +++ b/types/src/schemas/channelSchema.ts @@ -8,10 +8,9 @@ import { import { ChannelIconSchema } from './utilSchemas.js'; export const SubtitlesSchema = z.object({ - url: z.string().optional(), - enabled: z.boolean(), + path: z.string().optional(), + enabled: z.boolean().default(false), position: z.union([z.literal('top'), z.literal('bottom')]).default('bottom'), - opacity: z.number().min(0).max(100).int().optional().catch(100).default(100), }); export const WatermarkSchema = z.object({ diff --git a/types/src/schemas/settingsSchemas.ts b/types/src/schemas/settingsSchemas.ts index 47b6eb3d5..b37258e2e 100644 --- a/types/src/schemas/settingsSchemas.ts +++ b/types/src/schemas/settingsSchemas.ts @@ -94,7 +94,6 @@ export const FfmpegSettingsSchema = z.object({ .union([z.literal('h264'), z.literal('hevc'), z.literal('mpeg2')]) .default(DefaultVideoFormat), audioEncoder: z.string().default('aac'), - subtitlesEncoder: z.string().default('srt'), targetResolution: ResolutionSchema.default({ widthPx: 1920, heightPx: 1080 }), videoBitrate: z.number().default(10000), videoBufferSize: z.number().default(1000), From b944c4016ede870a07d8d39ab9c3ff70a02b6b54 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Fri, 10 Jan 2025 11:00:40 -0500 Subject: [PATCH 07/24] fix: clean more cruft --- server/src/db/derived_types/Lineup.ts | 14 -- server/src/db/mediaSourceDB.ts | 8 - .../ffmpeg/FfmpegPlaybackParamsCalculator.ts | 8 - .../ffmpeg/builder/state/SubtitlesState.ts | 49 ------ server/src/ffmpeg/builder/types.ts | 8 +- server/src/ffmpeg/ffmpeg.ts | 31 ---- server/src/serverContext.ts | 2 - server/src/services/SubtitlesService.ts | 165 ------------------ .../stream/local/LocalFileStreamDetails.ts | 25 +-- server/src/stream/types.ts | 9 - server/src/types/ffmpeg.ts | 11 -- 11 files changed, 2 insertions(+), 328 deletions(-) delete mode 100644 server/src/ffmpeg/builder/state/SubtitlesState.ts delete mode 100644 server/src/services/SubtitlesService.ts diff --git a/server/src/db/derived_types/Lineup.ts b/server/src/db/derived_types/Lineup.ts index 63ae94f7a..ad38941e5 100644 --- a/server/src/db/derived_types/Lineup.ts +++ b/server/src/db/derived_types/Lineup.ts @@ -79,20 +79,6 @@ export const OnDemandChannelConfigSchema = z.object({ export type OnDemandChannelConfig = z.infer; -<<<<<<< HEAD -export const SubtitlesConfigSchema = z.object({ - enabled: z.boolean().default(false).catch(false), - state: z - .union([z.literal('paused'), z.literal('playing')]) - .default('paused') - .catch('paused'), - cursor: z.number().nonnegative().default(0).catch(0), -}); - -export type SubtitlesConfig = z.infer; - -======= ->>>>>>> 2dcb4235 (feat: remove cruft) export const CurrentLineupSchemaVersion = 1; export const LineupSchema = z.object({ diff --git a/server/src/db/mediaSourceDB.ts b/server/src/db/mediaSourceDB.ts index 4072aaf6e..3ab44631a 100644 --- a/server/src/db/mediaSourceDB.ts +++ b/server/src/db/mediaSourceDB.ts @@ -204,14 +204,6 @@ export class MediaSourceDB { return newServer?.uuid; } - async getMediaSourceById(id: string): Promise { - return getDatabase() - .selectFrom('mediaSource') - .selectAll() - .where('mediaSource.uuid', '=', id) - .executeTakeFirst(); - } - // private async removeDanglingPrograms(mediaSource: MediaSource) { // const knownProgramIds = await directDbAccess() // .selectFrom('programExternalId as p1') diff --git a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts index 9372c8555..a1ccac6d6 100644 --- a/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts +++ b/server/src/ffmpeg/FfmpegPlaybackParamsCalculator.ts @@ -38,10 +38,6 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, -<<<<<<< HEAD - // subtitlesFormat: this.transcodeConfig.subtitlesFormat, -======= ->>>>>>> 2dcb4235 (feat: remove cruft) videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, @@ -114,10 +110,6 @@ export class FfmpegPlaybackParamsCalculator { audioChannels: this.transcodeConfig.audioChannels, audioSampleRate: this.transcodeConfig.audioSampleRate, hwAccel: this.transcodeConfig.hardwareAccelerationMode, -<<<<<<< HEAD - // subtitlesFormat: this.transcodeConfig.subtitlesFormat, -======= ->>>>>>> 2dcb4235 (feat: remove cruft) videoFormat: this.transcodeConfig.videoFormat, videoBitrate: this.transcodeConfig.videoBitRate, videoBufferSize: this.transcodeConfig.videoBufferSize, diff --git a/server/src/ffmpeg/builder/state/SubtitlesState.ts b/server/src/ffmpeg/builder/state/SubtitlesState.ts deleted file mode 100644 index 7b6f54304..000000000 --- a/server/src/ffmpeg/builder/state/SubtitlesState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ExcludeByValueType, Nullable } from '@/types/util.ts'; -import { isNil, omitBy } from 'lodash-es'; -import { AnyFunction } from 'ts-essentials'; - -export type SubtitlesStateFields = ExcludeByValueType< - SubtitlesState, - AnyFunction ->; - -const DefaultSubtitlesState: SubtitlesState = { - subtitlesEncoder: 'srt', - subtitlesLanguage: 'und', // 'und' for undefined - subtitlesCodec: null, - subtitlesFormat: 'srt', - subtitlesDelay: null, - subtitlesDuration: null, - subtitlesDefault: false, - subtitlesForced: false, -}; - -export class SubtitlesState { - subtitlesEncoder: string; - subtitlesLanguage: string; // Language code, e.g., 'en', 'es', 'fr' - subtitlesCodec: Nullable; - subtitlesFormat: Nullable; // Format like SRT, ASS, etc. - subtitlesDelay: Nullable; // Delay in milliseconds - subtitlesDuration: Nullable; // Duration in milliseconds - subtitlesDefault: boolean; // Whether it's the default subtitle - subtitlesForced: boolean; // Whether it's a forced subtitle - - private constructor(fields: Partial = {}) { - const merged: SubtitlesStateFields = { - ...DefaultSubtitlesState, - ...omitBy(fields, isNil), - }; - this.subtitlesEncoder = merged.subtitlesEncoder; - this.subtitlesLanguage = merged.subtitlesLanguage; - this.subtitlesCodec = merged.subtitlesCodec; - this.subtitlesFormat = merged.subtitlesFormat; - this.subtitlesDelay = merged.subtitlesDelay; - this.subtitlesDuration = merged.subtitlesDuration; - this.subtitlesDefault = merged.subtitlesDefault; - this.subtitlesForced = merged.subtitlesForced; - } - - static create(fields: Partial = {}) { - return new SubtitlesState(fields); - } -} diff --git a/server/src/ffmpeg/builder/types.ts b/server/src/ffmpeg/builder/types.ts index 6c6e695b2..183f9399b 100644 --- a/server/src/ffmpeg/builder/types.ts +++ b/server/src/ffmpeg/builder/types.ts @@ -28,13 +28,7 @@ export type HardwareAccelerationMode = TupleToUnion< typeof HardwareAccelerationModes >; -export const StreamKinds = [ - 'audio', - 'video', - 'all', - 'stillimage', - 'subtitles', -] as const; +export const StreamKinds = ['audio', 'video', 'all', 'stillimage'] as const; export type StreamKind = TupleToUnion; diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index 1a976a518..a775476a3 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -14,13 +14,8 @@ import { ChannelStreamMode, FfmpegSettings, Resolution, -<<<<<<< HEAD - Watermark, - Subtitles, -======= Subtitles, Watermark, ->>>>>>> 2dcb4235 (feat: remove cruft) } from '@tunarr/types'; import { NvidiaHardwareCapabilitiesFactory } from '@/ffmpeg/builder/capabilities/NvidiaHardwareCapabilitiesFactory.ts'; @@ -151,20 +146,12 @@ export type StreamOptions = { startTime: Duration; duration: Duration; watermark?: Watermark; -<<<<<<< HEAD subtitles?: Subtitles; -======= - subtitles: Subtitles; ->>>>>>> 2dcb4235 (feat: remove cruft) realtime?: boolean; // = true, extraInputHeaders?: Record; outputFormat: OutputFormat; ptsOffset?: number; -<<<<<<< HEAD - streamMode: ChannelStreamMode; -======= streamMode?: ChannelStreamMode; ->>>>>>> 2dcb4235 (feat: remove cruft) }; export type StreamSessionOptions = StreamOptions & { @@ -369,11 +356,6 @@ export class FFMPEG implements IFFMPEG { streamDetails, startTime, duration, -<<<<<<< HEAD - realtime, - enableSubs, -======= ->>>>>>> 2dcb4235 (feat: remove cruft) enableIcon, subtitles, realtime, @@ -419,11 +401,6 @@ export class FFMPEG implements IFFMPEG { streamStats, undefined, streamStats.duration!, -<<<<<<< HEAD - true, - /*subtitles=*/ undefined, -======= ->>>>>>> 2dcb4235 (feat: remove cruft) /*watermark=*/ undefined, /*subtitles=*/ undefined, true, @@ -450,10 +427,7 @@ export class FFMPEG implements IFFMPEG { duration, undefined, undefined, -<<<<<<< HEAD -======= true, ->>>>>>> 2dcb4235 (feat: remove cruft) outputFormat, null, ); @@ -464,11 +438,6 @@ export class FFMPEG implements IFFMPEG { streamStats: Maybe, startTime: Maybe, duration: Duration, -<<<<<<< HEAD - realtime: boolean, - subtitles: Maybe, -======= ->>>>>>> 2dcb4235 (feat: remove cruft) watermark: Maybe, subtitles: Maybe, realtime: boolean, diff --git a/server/src/serverContext.ts b/server/src/serverContext.ts index d55e17f82..42f5b3d54 100644 --- a/server/src/serverContext.ts +++ b/server/src/serverContext.ts @@ -15,7 +15,6 @@ import { FileCacheService } from './services/FileCacheService.ts'; import { HdhrService } from './services/HDHRService.ts'; import { HealthCheckService } from './services/HealthCheckService.js'; import { M3uService } from './services/M3UService.ts'; -import { SubtitlesService } from './services/SubtitlesService.ts'; import { OnDemandChannelService } from './services/OnDemandChannelService.js'; import { TVGuideService } from './services/TvGuideService.ts'; import { XmlTvWriter } from './services/XmlTvWriter.ts'; @@ -27,7 +26,6 @@ import { StreamProgramCalculator } from './stream/StreamProgramCalculator.js'; export class ServerContext { public readonly programConverter = new ProgramConverter(); public readonly sessionManager: SessionManager; - public readonly subtitlesService: SubtitlesService; public readonly onDemandChannelService: OnDemandChannelService; public readonly healthCheckService: HealthCheckService; diff --git a/server/src/services/SubtitlesService.ts b/server/src/services/SubtitlesService.ts deleted file mode 100644 index 941c496df..000000000 --- a/server/src/services/SubtitlesService.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ChannelDB } from '@/db/ChannelDB.ts'; -import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; -import { MutexMap } from '@/util/mutexMap.js'; -import dayjs from 'dayjs'; -import { isNull, isUndefined } from 'lodash-es'; - -export class SubtitlesService { - #logger = LoggerFactory.child({ className: this.constructor.name }); - #locks: MutexMap = new MutexMap(); - - constructor(private channelDB: ChannelDB) {} - - async isChannelPlaying(id: string) { - const channelAndLineup = await this.channelDB.loadChannelAndLineup(id); - if (isNull(channelAndLineup)) { - return false; - } - - const { lineup } = channelAndLineup; - - return lineup.subtitlesConfig?.state === 'playing'; - } - - async pauseAllChannels() { - const allConfigs = await this.channelDB.loadAllLineupConfigs(); - const now = dayjs().unix() * 1000; - for (const [channelId, { lineup }] of Object.entries(allConfigs)) { - if (isUndefined(lineup.onDemandConfig)) { - continue; - } - - if (lineup.onDemandConfig.state === 'paused') { - continue; - } - - await this.channelDB.updateLineupConfig(channelId, 'onDemandConfig', { - ...lineup.onDemandConfig, - state: 'paused', - lastPaused: now, - }); - } - } - - async pauseChannel(id: string, stopTime?: number, rewindMs: number = 0) { - return this.#locks.runWithLockId(id, async () => { - const channelAndLineup = await this.loadOnDemandChannelLineup(id); - - if (isUndefined(channelAndLineup)) { - return; - } - - const { channel, lineup } = channelAndLineup; - - if (isUndefined(lineup.onDemandConfig)) { - return; - } - - if (lineup.onDemandConfig.state === 'paused') { - return; - } - - // TODO: What happens if the channel lineup is modified whille - // the stream is active? - const pauseTime = stopTime ?? dayjs().valueOf(); - const lastResumed = - lineup.onDemandConfig.lastResumed ?? channel.startTime; - const elapsed = pauseTime - lastResumed; - // If the channel was updated after the channel was resumed, zero out the - // cursor and start the channel from the beginning. - const nextCursor = - lineup.lastUpdated > lastResumed - ? 0 - : (lineup.onDemandConfig.cursor + elapsed - rewindMs) % - channel.duration; - - return await this.channelDB - .updateLineupConfig(id, 'onDemandConfig', { - ...(lineup.onDemandConfig ?? {}), - state: 'paused', - lastPaused: pauseTime, - cursor: nextCursor, - }) - .finally(() => { - this.#logger.debug( - 'Paused on-demand channel %s (at = %s)', - id, - dayjs(pauseTime).format(), - ); - }); - }); - } - - async resumeChannel(id: string) { - return this.#locks.runWithLockId(id, async () => { - const channelAndLineup = await this.loadOnDemandChannelLineup(id); - - if (isUndefined(channelAndLineup)) { - return; - } - - const { lineup } = channelAndLineup; - - if (isUndefined(lineup.onDemandConfig)) { - return; - } - - if (lineup.onDemandConfig.state === 'playing') { - return; - } - - // TODO: Find the current program at the last cursor - // and skip it if it's a commercial. - - const now = dayjs(); - return await this.channelDB - .updateLineupConfig(id, 'onDemandConfig', { - ...(lineup.onDemandConfig ?? {}), - state: 'playing', - lastResumed: +now, - }) - .finally(() => { - this.#logger.debug( - 'Resumed on-demand channel %s (at = %s)', - id, - now.format(), - ); - }); - }); - } - - async getLiveTimestamp(channelId: string, requestTime: number) { - const channelAndLineup = await this.loadOnDemandChannelLineup(channelId); - - if (isUndefined(channelAndLineup)) { - return requestTime; - } - - const { channel, lineup } = channelAndLineup; - - if (isUndefined(lineup.onDemandConfig)) { - return requestTime; - } - - let sinceResume = dayjs(requestTime).diff( - dayjs(lineup.onDemandConfig.lastResumed), - ); - - // Don't skip milliseconds - if (sinceResume < 1_000) { - sinceResume = 0; - } - - return channel.startTime + lineup.onDemandConfig.cursor + sinceResume; - } - - private async loadOnDemandChannelLineup(id: string) { - const channelAndLineup = - await this.channelDB.loadDirectChannelAndLineup(id); - if (isNull(channelAndLineup)) { - return; - } - - return channelAndLineup; - } -} diff --git a/server/src/stream/local/LocalFileStreamDetails.ts b/server/src/stream/local/LocalFileStreamDetails.ts index e45b354a6..ecec2d490 100644 --- a/server/src/stream/local/LocalFileStreamDetails.ts +++ b/server/src/stream/local/LocalFileStreamDetails.ts @@ -1,10 +1,6 @@ import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.ts'; -import { - FfprobeAudioStream, - FfprobeVideoStream, - FfprobeSubtitlesStream, -} from '@/types/ffmpeg.ts'; +import { FfprobeAudioStream, FfprobeVideoStream } from '@/types/ffmpeg.ts'; import { Maybe, Nullable } from '@/types/util.ts'; import dayjs from '@/util/dayjs.ts'; import { fileExists } from '@/util/fsUtil.ts'; @@ -17,7 +13,6 @@ import { FileStreamSource, HttpStreamSource, ProgramStreamResult, - SubtitlesStreamDetails, VideoStreamDetails, } from '../types.ts'; @@ -95,30 +90,12 @@ export class LocalFileStreamDetails { }, ); - const subtitlesStreamDetails = map( - filter( - probeResult.streams, - (stream): stream is FfprobeSubtitlesStream => - stream.codec_type === 'subtitles', - ), - (subtitlesStream) => { - return { - codec: subtitlesStream.codec_name, - index: subtitlesStream.index.toFixed(), - profile: subtitlesStream.profile, - } satisfies SubtitlesStreamDetails; - }, - ); - return { streamDetails: { videoDetails: videoDetails ? [videoDetails] : undefined, audioDetails: isEmpty(audioStreamDetails) ? undefined : (audioStreamDetails as NonEmptyArray), - subtitlesDetails: isEmpty(subtitlesStreamDetails) - ? undefined - : (subtitlesStreamDetails as NonEmptyArray), duration: dayjs.duration({ seconds: probeResult.format.duration }), }, streamSource: this.path.startsWith('http') diff --git a/server/src/stream/types.ts b/server/src/stream/types.ts index 3b326901a..0a519751b 100644 --- a/server/src/stream/types.ts +++ b/server/src/stream/types.ts @@ -19,7 +19,6 @@ export type StreamDetails = { // If defined, there is at least one video stream videoDetails?: [VideoStreamDetails, ...VideoStreamDetails[]]; audioDetails?: [AudioStreamDetails, ...AudioStreamDetails[]]; - subtitlesDetails?: [SubtitlesStreamDetails, ...SubtitlesStreamDetails[]]; audioOnly?: boolean; placeholderImage?: string; @@ -57,14 +56,6 @@ export type AudioStreamDetails = { forced?: boolean; }; -export type SubtitlesStreamDetails = { - index?: string; - selected?: boolean; - title?: string; - language?: string; - forced?: boolean; -}; - // TODO: Move me export function getPixelFormatForStream(details: StreamDetails) { if (isUndefined(first(details.videoDetails))) { diff --git a/server/src/types/ffmpeg.ts b/server/src/types/ffmpeg.ts index 42501bd5e..8ddde61a8 100644 --- a/server/src/types/ffmpeg.ts +++ b/server/src/types/ffmpeg.ts @@ -58,16 +58,6 @@ export const FfprobeAudioStreamSchema = BaseFfprobeMediaStreamSchema.extend({ export type FfprobeAudioStream = z.infer; -export const FfprobeSubtitlesStreamSchema = BaseFfprobeMediaStreamSchema.extend( - { - codec_type: z.literal('subtitles'), - }, -); - -export type FfprobeSubtitlesStream = z.infer< - typeof FfprobeSubtitlesStreamSchema ->; - function parsePossibleFractionToFloat(s: string) { if (s.includes('/')) { const [num, den] = split(s, '/', 2); @@ -84,7 +74,6 @@ function parsePossibleFractionToFloat(s: string) { export const FfprobeMediaStreamSchema = z.discriminatedUnion('codec_type', [ FfprobeVideoStreamSchema, FfprobeAudioStreamSchema, - FfprobeSubtitlesStreamSchema, ]); export const FfprobeMediaFormatSchema = z.object({ From d4d983145d1f6359f23db10b9fa330c26947ab71 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Fri, 10 Jan 2025 11:52:27 -0500 Subject: [PATCH 08/24] fix: add debugging for subs --- server/src/api/streamApi.ts | 4 --- server/src/db/ChannelDB.ts | 29 +------------------ server/src/ffmpeg/ffmpeg.ts | 16 ++++------ .../stream/jellyfin/JellyfinProgramStream.ts | 12 +++++--- types/src/schemas/channelSchema.ts | 3 +- .../channel_config/MediaGridItem.tsx | 6 ++-- 6 files changed, 20 insertions(+), 50 deletions(-) diff --git a/server/src/api/streamApi.ts b/server/src/api/streamApi.ts index 9f9b9fed8..2d10e4255 100644 --- a/server/src/api/streamApi.ts +++ b/server/src/api/streamApi.ts @@ -138,10 +138,6 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => { const session = sessionResult.get(); - if (lineup.subtitlesConfig) { - await req.serverCtx.subtitlesService.resumeChannel(channel.uuid); - } - if (lineup.onDemandConfig) { await req.serverCtx.onDemandChannelService.resumeChannel(channel.uuid); } diff --git a/server/src/db/ChannelDB.ts b/server/src/db/ChannelDB.ts index 4b73083b8..002b455c7 100644 --- a/server/src/db/ChannelDB.ts +++ b/server/src/db/ChannelDB.ts @@ -219,11 +219,7 @@ type UpdateChannelLineupRequest = MarkOptional< | 'schedulingOperations' | 'pendingPrograms' >, - | 'version' - | 'onDemandConfig' - | 'subtitlesConfig' - | 'items' - | 'startTimeOffsets' + 'version' | 'onDemandConfig' | 'items' | 'startTimeOffsets' >; export class ChannelDB { private logger = LoggerFactory.child({ @@ -395,13 +391,6 @@ export class ChannelDB { await this.createLineup(channel.uuid); - if (isDefined(createReq.subtitles) && createReq.subtitles.enabled) { - const db = await this.getFileDb(channel.uuid); - await db.update((lineup) => { - lineup.subtitlesConfig.enabled = false; - }); - } - if (isDefined(createReq.onDemand) && createReq.onDemand.enabled) { const db = await this.getFileDb(channel.uuid); await db.update((lineup) => { @@ -482,17 +471,6 @@ export class ChannelDB { } }); - if (isDefined(updateReq.subtitles)) { - const db = await this.getFileDb(id); - await db.update((lineup) => { - if (updateReq.subtitles?.enabled ?? false) { - lineup.subtitlesConfig.enabled = false; - } else { - delete lineup['subtitlesConfig']; - } - }); - } - if (isDefined(updateReq.onDemand)) { const db = await this.getFileDb(id); await db.update((lineup) => { @@ -763,11 +741,6 @@ export class ChannelDB { cursor: 0, } : undefined, - subtitlesConfig: isDefined(lineup.subtitlesConfig) - ? { - ...lineup.subtitlesConfig, - } - : undefined, }), ); diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index a775476a3..7c3fa9d92 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -14,7 +14,6 @@ import { ChannelStreamMode, FfmpegSettings, Resolution, - Subtitles, Watermark, } from '@tunarr/types'; @@ -146,7 +145,7 @@ export type StreamOptions = { startTime: Duration; duration: Duration; watermark?: Watermark; - subtitles?: Subtitles; + subtitles?: string; realtime?: boolean; // = true, extraInputHeaders?: Record; outputFormat: OutputFormat; @@ -343,7 +342,6 @@ export class FFMPEG implements IFFMPEG { streamDetails, startTime, duration, - subtitles: enableSubs, watermark: enableIcon, subtitles, realtime = true, @@ -439,7 +437,8 @@ export class FFMPEG implements IFFMPEG { startTime: Maybe, duration: Duration, watermark: Maybe, - subtitles: Maybe, + //subtitles: Maybe, + subtitles: Maybe, realtime: boolean, outputFormat: OutputFormat, ptsOffset: Nullable, @@ -739,8 +738,9 @@ export class FFMPEG implements IFFMPEG { iH = iH!; } - if (subtitles?.enabled && !isNil(subtitles?.path)) { - ffmpegArgs.push(`-vf`, `${subtitles.path}`); + // if (subtitles?.enabled && !isNil(subtitles?.path)) { + if (!isNil(subtitles)) { + ffmpegArgs.push(`-i`, `${subtitles}`); } if (doOverlay && !isNil(watermark?.url)) { @@ -752,10 +752,6 @@ export class FFMPEG implements IFFMPEG { this.ensureResolution = true; } - if (!isNil(subtitles?.url)) { - ffmpegArgs.push(`-i`, `${subtitles.url}`); - } - // Resolution fix: Add scale filter, current stream becomes [siz] const beforeSizeChange = currentVideo; const algo = 'bicubic'; // Scaling up - hardcode bicubic. diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index d69c8178c..9de8515f4 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs'; import { ChannelDB } from '@/db/ChannelDB.ts'; import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { isContentBackedLineupIteam } from '@/db/derived_types/StreamLineup.ts'; @@ -99,13 +100,16 @@ export class JellyfinProgramStream extends ProgramStream { : undefined; } - const sourcePath = stream.streamSource.path; + const sourcePath = stream.streamDetails.directFilePath; + console.log(sourcePath); let subtitlesPath: Nullable = null; - if (sourcePath.replace(/\.[^/.]+$/, '.srt')) { + if (sourcePath) { subtitlesPath = sourcePath.replace(/\.[^/.]+$/, '.srt'); - } else { - this.logger.warn(`No subtitle file found at ${subtitlesPath}`); + if (!existsSync(subtitlesPath)) { + subtitlesPath = null; // Set to null if the file doesn't exist + } } + console.log(subtitlesPath); const start = dayjs.duration(lineupItem.startOffset ?? 0); diff --git a/types/src/schemas/channelSchema.ts b/types/src/schemas/channelSchema.ts index ae2e8ddb5..0a53b6ec3 100644 --- a/types/src/schemas/channelSchema.ts +++ b/types/src/schemas/channelSchema.ts @@ -128,7 +128,8 @@ export const ChannelSchema = z.object({ stealth: z.boolean(), transcoding: ChannelTranscodingOptionsSchema.optional(), watermark: WatermarkSchema.optional(), - subtitles: SubtitlesSchema.optional(), + // subtitles: SubtitlesSchema.optional(), + subtitles: z.string().optional(), onDemand: z.object({ enabled: z.boolean(), }), diff --git a/web/src/components/channel_config/MediaGridItem.tsx b/web/src/components/channel_config/MediaGridItem.tsx index ec3a5fa55..171b06c29 100644 --- a/web/src/components/channel_config/MediaGridItem.tsx +++ b/web/src/components/channel_config/MediaGridItem.tsx @@ -38,7 +38,7 @@ export type GridItemMetadata = { childCount: number | null; aspectRatio: 'portrait' | 'landscape' | 'square'; title: string; - subtitles: JSX.Element | string | null; + subtitle: JSX.Element | string | null; thumbnailUrl: string; selectedMedia: SelectedMedia; }; @@ -75,7 +75,7 @@ const MediaGridItemInner = ( selectedMedia: selectedMediaItem, aspectRatio, title, - subtitles, + subtitle, childCount, }, style, @@ -236,7 +236,7 @@ const MediaGridItemInner = ( ))} Date: Fri, 10 Jan 2025 14:35:11 -0500 Subject: [PATCH 09/24] fix: outline list dir api call --- .../external/jellyfin/JellyfinApiClient.ts | 17 +++++++ .../stream/jellyfin/JellyfinProgramStream.ts | 49 +++++++++++++++---- .../ChannelPropertiesEditor.tsx | 2 + .../channel_config/EditChannelForm.tsx | 6 +-- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/server/src/external/jellyfin/JellyfinApiClient.ts b/server/src/external/jellyfin/JellyfinApiClient.ts index ce0267767..c71576671 100644 --- a/server/src/external/jellyfin/JellyfinApiClient.ts +++ b/server/src/external/jellyfin/JellyfinApiClient.ts @@ -339,6 +339,23 @@ export class JellyfinApiClient extends BaseApiClient { return `${opts.uri}/Items/${opts.itemKey}/Images/Primary`; } + async getDirectoryContents(path: string) { + try { + // Use the doTypeCheckedGet method with the correct endpoint and path param + const response = await this.doTypeCheckedGet( + '/Environment/DirectoryContents', + JellyfinLibraryItemsResponse, + { params: { path } } + ); + return response; + } catch (error) { + LoggerFactory.root.error(error, 'Error fetching directory contents', { + className: JellyfinApiClient.name, + }); + throw error; + } + } + protected override preRequestValidate( req: AxiosRequestConfig, ): Maybe { diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index 9de8515f4..f001e71a1 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -1,4 +1,5 @@ -import { existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { readdirSync } from 'fs'; import { ChannelDB } from '@/db/ChannelDB.ts'; import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { isContentBackedLineupIteam } from '@/db/derived_types/StreamLineup.ts'; @@ -18,6 +19,7 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; import dayjs from 'dayjs'; import { isNil, isNull, isUndefined } from 'lodash-es'; import { JellyfinStreamDetails } from './JellyfinStreamDetails.js'; +import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.ts'; export class JellyfinProgramStream extends ProgramStream { protected logger = LoggerFactory.child({ @@ -101,15 +103,42 @@ export class JellyfinProgramStream extends ProgramStream { } const sourcePath = stream.streamDetails.directFilePath; + console.log(server.uri) console.log(sourcePath); - let subtitlesPath: Nullable = null; - if (sourcePath) { - subtitlesPath = sourcePath.replace(/\.[^/.]+$/, '.srt'); - if (!existsSync(subtitlesPath)) { - subtitlesPath = null; // Set to null if the file doesn't exist - } - } - console.log(subtitlesPath); + + const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName("JF"); + const adjacentItems = await jellyfinClient.getDirectoryContents(sourcePath); + + console.log(adjacentItems) + + // let subtitlesPath: Nullable = null; + + // if (sourcePath) { + // const sourceDir = dirname(sourcePath); + // console.log(`Source Directory: ${sourceDir}`); + // + // try { + // // List all files in the directory + // const dirContents = readdirSync(sourceDir); + // console.log('Directory Contents:', dirContents); + // + // const baseName = sourcePath.replace(/\.[^/.]+$/, ''); // Strip the extension + // console.log(`Base Name: ${baseName}`); + // + // const potentialSubtitles = dirContents.filter((file) => + // file.startsWith(baseName) && file.endsWith('.srt') + // ); + // + // // Use the first match, if available + // if (potentialSubtitles.length > 0) { + // subtitlesPath = join(sourceDir, potentialSubtitles[0]); + // } + // } catch (err) { + // console.error(`Failed to read directory: ${sourceDir}`, err); + // } + // } + + // console.log(`Subtitles Path: ${subtitlesPath}`); const start = dayjs.duration(lineupItem.startOffset ?? 0); @@ -122,7 +151,7 @@ export class JellyfinProgramStream extends ProgramStream { ? dayjs.duration(lineupItem.duration) : dayjs.duration(lineupItem.streamDuration ?? lineupItem.duration), watermark, - subtitles: subtitlesPath ? subtitlesPath : undefined, // Pass subtitle file if it exists + subtitles: null, realtime: this.context.realtime, extraInputHeaders: {}, outputFormat: this.outputFormat, diff --git a/web/src/components/channel_config/ChannelPropertiesEditor.tsx b/web/src/components/channel_config/ChannelPropertiesEditor.tsx index 47941d6f0..b941b3cda 100644 --- a/web/src/components/channel_config/ChannelPropertiesEditor.tsx +++ b/web/src/components/channel_config/ChannelPropertiesEditor.tsx @@ -227,6 +227,7 @@ export function ChannelPropertiesEditor({ isNew }: Props) { /> + {/* Subtitles @@ -253,6 +254,7 @@ export function ChannelPropertiesEditor({ isNew }: Props) { )} /> + */} On-Demand diff --git a/web/src/components/channel_config/EditChannelForm.tsx b/web/src/components/channel_config/EditChannelForm.tsx index f5bc5f429..c2ea13522 100644 --- a/web/src/components/channel_config/EditChannelForm.tsx +++ b/web/src/components/channel_config/EditChannelForm.tsx @@ -71,9 +71,9 @@ function getDefaultFormValues(channel: Channel): SaveChannelRequest { }, ], }, - subtitles: { - enabled: channel.subtitles?.enabled ?? false, - }, + // subtitles: { + // enabled: channel.subtitles?.enabled ?? false, + // }, onDemand: { enabled: channel.onDemand.enabled, }, From 55dea7ff36bed9b27e63c4021fafb2a35282df99 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Fri, 10 Jan 2025 16:28:43 -0500 Subject: [PATCH 10/24] Query server for similar paths --- .../external/jellyfin/JellyfinApiClient.ts | 44 ++++++++---- .../stream/jellyfin/JellyfinProgramStream.ts | 67 +++++++++---------- types/src/jellyfin/index.ts | 16 +++++ 3 files changed, 80 insertions(+), 47 deletions(-) diff --git a/server/src/external/jellyfin/JellyfinApiClient.ts b/server/src/external/jellyfin/JellyfinApiClient.ts index c71576671..a2a063f00 100644 --- a/server/src/external/jellyfin/JellyfinApiClient.ts +++ b/server/src/external/jellyfin/JellyfinApiClient.ts @@ -4,6 +4,7 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.ts'; import { getTunarrVersion } from '@/util/version.js'; import { JellyfinAuthenticationResult, + JellyfinDirItemsArrayResponse, JellyfinItem, JellyfinItemFields, JellyfinItemKind, @@ -339,22 +340,41 @@ export class JellyfinApiClient extends BaseApiClient { return `${opts.uri}/Items/${opts.itemKey}/Images/Primary`; } - async getDirectoryContents(path: string) { - try { - // Use the doTypeCheckedGet method with the correct endpoint and path param - const response = await this.doTypeCheckedGet( - '/Environment/DirectoryContents', - JellyfinLibraryItemsResponse, - { params: { path } } - ); - return response; - } catch (error) { - LoggerFactory.root.error(error, 'Error fetching directory contents', { +async getDirectoryContents(path: string) { + try { + const response = await this.doTypeCheckedGet( + '/Environment/DirectoryContents', + JellyfinDirItemsArrayResponse, // Expecting an array of JellyfinLibraryItem objects + { + params: { + path, + includeDirectories: true, + includeFiles: true, + }, + } + ); + + // Check if response is an array + if (Array.isArray(response)) { + // If it's an array, extract only the 'Name' field + const names = response.map(item => item.Name); + return names; // Return only the 'Name' values + } else { + // Handle the case where response is not an array + LoggerFactory.root.error('Unexpected response format', 'Response is not an array', { className: JellyfinApiClient.name, }); - throw error; + throw new Error('Unexpected response format, expected an array'); } + + } catch (error) { + LoggerFactory.root.error(error, 'Error fetching directory contents', { + className: JellyfinApiClient.name, + }); + throw error; } +} + protected override preRequestValidate( req: AxiosRequestConfig, diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index f001e71a1..4a56a60a2 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -103,42 +103,39 @@ export class JellyfinProgramStream extends ProgramStream { } const sourcePath = stream.streamDetails.directFilePath; - console.log(server.uri) - console.log(sourcePath); - - const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName("JF"); - const adjacentItems = await jellyfinClient.getDirectoryContents(sourcePath); - - console.log(adjacentItems) - - // let subtitlesPath: Nullable = null; - - // if (sourcePath) { - // const sourceDir = dirname(sourcePath); - // console.log(`Source Directory: ${sourceDir}`); - // - // try { - // // List all files in the directory - // const dirContents = readdirSync(sourceDir); - // console.log('Directory Contents:', dirContents); - // - // const baseName = sourcePath.replace(/\.[^/.]+$/, ''); // Strip the extension - // console.log(`Base Name: ${baseName}`); - // - // const potentialSubtitles = dirContents.filter((file) => - // file.startsWith(baseName) && file.endsWith('.srt') - // ); - // - // // Use the first match, if available - // if (potentialSubtitles.length > 0) { - // subtitlesPath = join(sourceDir, potentialSubtitles[0]); - // } - // } catch (err) { - // console.error(`Failed to read directory: ${sourceDir}`, err); - // } - // } + let subtitlesPath: Nullable = null; + + if (sourcePath) { + const sourceDir = dirname(sourcePath); + console.log(`Source Directory: ${sourceDir}`); + + try { + const baseName = sourcePath.replace(/\.[^/.]+$/, ''); // Strip the extension + console.log(`Base Name: ${baseName}`); + + // List all files in the directory + const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName("JF"); + const adjacentItems = await jellyfinClient.getDirectoryContents(sourceDir); + + console.log(Array.isArray(adjacentItems)); + console.log(adjacentItems); + + // Filter for subtitles: match file names (without extensions) and look for .srt files + const potentialSubtitles = adjacentItems.filter((item) => + item.Path.startsWith(baseName) && item.Path.endsWith('.srt') + ); + + // Use the first match, if available + if (potentialSubtitles.length > 0) { + subtitlesPath = join(sourceDir, potentialSubtitles[0].Path); // Use Path here + } + } catch (err) { + console.error(`Failed to read directory: ${sourceDir}`, err); + } + } + + console.log(`Subtitles Path: ${subtitlesPath}`); - // console.log(`Subtitles Path: ${subtitlesPath}`); const start = dayjs.duration(lineupItem.startOffset ?? 0); diff --git a/types/src/jellyfin/index.ts b/types/src/jellyfin/index.ts index c4c034809..4312fb12e 100644 --- a/types/src/jellyfin/index.ts +++ b/types/src/jellyfin/index.ts @@ -856,6 +856,22 @@ export type JellyfinLibraryItemsResponse = z.infer< typeof JellyfinLibraryItemsResponse >; +// Define the schema for an individual directory item +export const JellyfinDirItemsResponse = z.object({ + Name: z.string(), // The name of the item (e.g., file name) + Path: z.string(), // The full path of the item + Type: z.enum(['File', 'Directory']), // Type, which can be either 'File' or 'Directory' +}); + +// Now, we define the response as an array of `JellyfinDirItemsResponse` +export const JellyfinDirItemsArrayResponse = z.array(JellyfinDirItemsResponse); + +// Type for an individual item +export type JellyfinDirItem = z.infer; + +// Type for the array of items +export type JellyfinDirItemsArrayResponse = z.infer; + const JellyfinSessionInfo = z .object({ // PlayState: PlayerStateInfo.nullable().optional(), From a9ad7441b1a07ae57f4d25e12afc281000ab0f36 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Fri, 10 Jan 2025 17:40:03 -0500 Subject: [PATCH 11/24] Match potential sub --- .../external/jellyfin/JellyfinApiClient.ts | 60 +++++++++---------- .../stream/jellyfin/JellyfinProgramStream.ts | 14 +++-- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/server/src/external/jellyfin/JellyfinApiClient.ts b/server/src/external/jellyfin/JellyfinApiClient.ts index a2a063f00..8ff323c3f 100644 --- a/server/src/external/jellyfin/JellyfinApiClient.ts +++ b/server/src/external/jellyfin/JellyfinApiClient.ts @@ -340,41 +340,41 @@ export class JellyfinApiClient extends BaseApiClient { return `${opts.uri}/Items/${opts.itemKey}/Images/Primary`; } -async getDirectoryContents(path: string) { - try { - const response = await this.doTypeCheckedGet( - '/Environment/DirectoryContents', - JellyfinDirItemsArrayResponse, // Expecting an array of JellyfinLibraryItem objects - { - params: { - path, - includeDirectories: true, - includeFiles: true, - }, + async getDirectoryContents(path: string) { + try { + const response = await this.doTypeCheckedGet( + '/Environment/DirectoryContents', + JellyfinDirItemsArrayResponse, // Expecting an array of JellyfinLibraryItem objects + { + params: { + path, + includeDirectories: true, + includeFiles: true, + }, + } + ); + + // Check if response contains the 'data' field and it is an array + if (response?.data && Array.isArray(response.data)) { + // If it's an array, extract only the 'Name' field + const names = response.data.map((item) => item.Name); + return names; // Return only the 'Name' values + } else { + // Handle the case where response is not in the expected format + LoggerFactory.root.error( + 'Unexpected response format', + 'Response does not contain a data array', + { className: JellyfinApiClient.name } + ); + throw new Error('Unexpected response format, expected a data array'); } - ); - - // Check if response is an array - if (Array.isArray(response)) { - // If it's an array, extract only the 'Name' field - const names = response.map(item => item.Name); - return names; // Return only the 'Name' values - } else { - // Handle the case where response is not an array - LoggerFactory.root.error('Unexpected response format', 'Response is not an array', { + } catch (error) { + LoggerFactory.root.error(error, 'Error fetching directory contents', { className: JellyfinApiClient.name, }); - throw new Error('Unexpected response format, expected an array'); + throw error; } - - } catch (error) { - LoggerFactory.root.error(error, 'Error fetching directory contents', { - className: JellyfinApiClient.name, - }); - throw error; } -} - protected override preRequestValidate( req: AxiosRequestConfig, diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index 4a56a60a2..20d086a96 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -1,4 +1,4 @@ -import { dirname, join } from 'path'; +import { basename, dirname, join } from 'path'; import { readdirSync } from 'fs'; import { ChannelDB } from '@/db/ChannelDB.ts'; import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; @@ -103,6 +103,7 @@ export class JellyfinProgramStream extends ProgramStream { } const sourcePath = stream.streamDetails.directFilePath; + let baseNameExt = basename(sourcePath) let subtitlesPath: Nullable = null; if (sourcePath) { @@ -110,24 +111,25 @@ export class JellyfinProgramStream extends ProgramStream { console.log(`Source Directory: ${sourceDir}`); try { - const baseName = sourcePath.replace(/\.[^/.]+$/, ''); // Strip the extension + const baseName = baseNameExt.replace(/\.[^/.]+$/, ''); // Strip the extension console.log(`Base Name: ${baseName}`); // List all files in the directory const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName("JF"); const adjacentItems = await jellyfinClient.getDirectoryContents(sourceDir); - console.log(Array.isArray(adjacentItems)); console.log(adjacentItems); // Filter for subtitles: match file names (without extensions) and look for .srt files - const potentialSubtitles = adjacentItems.filter((item) => - item.Path.startsWith(baseName) && item.Path.endsWith('.srt') + const potentialSubtitles = adjacentItems.filter((fileName) => + fileName.startsWith(baseName) && fileName.endsWith('.srt') ); + + console.log("Potential Subtitles:", potentialSubtitles); // Use the first match, if available if (potentialSubtitles.length > 0) { - subtitlesPath = join(sourceDir, potentialSubtitles[0].Path); // Use Path here + subtitlesPath = join(sourceDir, potentialSubtitles[0]); // Combine directory with file name } } catch (err) { console.error(`Failed to read directory: ${sourceDir}`, err); From 0e35407543c4a6b454b0d2e0b45cd1867d7597d9 Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Sun, 12 Jan 2025 15:35:42 -0500 Subject: [PATCH 12/24] Work towards native implemenation --- .../external/jellyfin/JellyfinApiClient.ts | 36 ----------- server/src/ffmpeg/ffmpeg.ts | 27 ++++----- .../stream/jellyfin/JellyfinProgramStream.ts | 60 +++++++------------ server/src/stream/types.ts | 5 +- web/src/components/Video.tsx | 11 +++- web/src/pages/watch/ChannelWatchPage.tsx | 2 +- 6 files changed, 48 insertions(+), 93 deletions(-) diff --git a/server/src/external/jellyfin/JellyfinApiClient.ts b/server/src/external/jellyfin/JellyfinApiClient.ts index 8ff323c3f..36b174edd 100644 --- a/server/src/external/jellyfin/JellyfinApiClient.ts +++ b/server/src/external/jellyfin/JellyfinApiClient.ts @@ -4,7 +4,6 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.ts'; import { getTunarrVersion } from '@/util/version.js'; import { JellyfinAuthenticationResult, - JellyfinDirItemsArrayResponse, JellyfinItem, JellyfinItemFields, JellyfinItemKind, @@ -340,41 +339,6 @@ export class JellyfinApiClient extends BaseApiClient { return `${opts.uri}/Items/${opts.itemKey}/Images/Primary`; } - async getDirectoryContents(path: string) { - try { - const response = await this.doTypeCheckedGet( - '/Environment/DirectoryContents', - JellyfinDirItemsArrayResponse, // Expecting an array of JellyfinLibraryItem objects - { - params: { - path, - includeDirectories: true, - includeFiles: true, - }, - } - ); - - // Check if response contains the 'data' field and it is an array - if (response?.data && Array.isArray(response.data)) { - // If it's an array, extract only the 'Name' field - const names = response.data.map((item) => item.Name); - return names; // Return only the 'Name' values - } else { - // Handle the case where response is not in the expected format - LoggerFactory.root.error( - 'Unexpected response format', - 'Response does not contain a data array', - { className: JellyfinApiClient.name } - ); - throw new Error('Unexpected response format, expected a data array'); - } - } catch (error) { - LoggerFactory.root.error(error, 'Error fetching directory contents', { - className: JellyfinApiClient.name, - }); - throw error; - } - } protected override preRequestValidate( req: AxiosRequestConfig, diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index 7c3fa9d92..19f1042cb 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -145,7 +145,7 @@ export type StreamOptions = { startTime: Duration; duration: Duration; watermark?: Watermark; - subtitles?: string; + subtitles?: boolean; realtime?: boolean; // = true, extraInputHeaders?: Record; outputFormat: OutputFormat; @@ -206,7 +206,7 @@ export class FFMPEG implements IFFMPEG { `-threads`, '1', '-loglevel', - this.opts.logLevel, + this.opts.logLevel, '-user_agent', `Ffmpeg Tunarr/${getTunarrVersion()}`, `-fflags`, @@ -226,8 +226,6 @@ export class FFMPEG implements IFFMPEG { streamUrl, ]; - console.log('FFmpeg Arguments:', ffmpegArgs.join(' ')); - // Workaround until new pipeline is in place... const scThreshold = this.transcodeConfig.videoFormat.includes('mpeg2') ? '1000000000' @@ -343,7 +341,7 @@ export class FFMPEG implements IFFMPEG { startTime, duration, watermark: enableIcon, - subtitles, + subtitles = false, realtime = true, outputFormat, ptsOffset, @@ -400,7 +398,7 @@ export class FFMPEG implements IFFMPEG { undefined, streamStats.duration!, /*watermark=*/ undefined, - /*subtitles=*/ undefined, + /*subtitles=*/ false, true, outputFormat, null, @@ -424,7 +422,7 @@ export class FFMPEG implements IFFMPEG { undefined, duration, undefined, - undefined, + false, true, outputFormat, null, @@ -437,8 +435,7 @@ export class FFMPEG implements IFFMPEG { startTime: Maybe, duration: Duration, watermark: Maybe, - //subtitles: Maybe, - subtitles: Maybe, + subtitles: boolean, realtime: boolean, outputFormat: OutputFormat, ptsOffset: Nullable, @@ -738,9 +735,12 @@ export class FFMPEG implements IFFMPEG { iH = iH!; } - // if (subtitles?.enabled && !isNil(subtitles?.path)) { - if (!isNil(subtitles)) { - ffmpegArgs.push(`-i`, `${subtitles}`); + if (subtitles) { + console.log('Adding sub opts'); + ffmpegArgs.push( + '-map', + '0:s:0', + ); } if (doOverlay && !isNil(watermark?.url)) { @@ -1125,7 +1125,7 @@ export class FFMPEG implements IFFMPEG { this.logger.info('ffmpeg preemptively killed'); return; } - + console.log(ffmpegArgs) return this.createProcess(ffmpegArgs, duration); } @@ -1134,7 +1134,6 @@ export class FFMPEG implements IFFMPEG { streamDuration?: Duration, ): FfmpegTranscodeSession { const process = new FfmpegProcess(this.opts, this.ffmpegName, ffmpegArgs); - // TODO: Do we need a more accurate measure of "streamEndTime" by passing in // the request start time? Or is this really inaccurate because we still have // a short amount of time before the stream is actually started... diff --git a/server/src/stream/jellyfin/JellyfinProgramStream.ts b/server/src/stream/jellyfin/JellyfinProgramStream.ts index 20d086a96..480870638 100644 --- a/server/src/stream/jellyfin/JellyfinProgramStream.ts +++ b/server/src/stream/jellyfin/JellyfinProgramStream.ts @@ -1,5 +1,3 @@ -import { basename, dirname, join } from 'path'; -import { readdirSync } from 'fs'; import { ChannelDB } from '@/db/ChannelDB.ts'; import { SettingsDB, getSettings } from '@/db/SettingsDB.ts'; import { isContentBackedLineupIteam } from '@/db/derived_types/StreamLineup.ts'; @@ -102,42 +100,26 @@ export class JellyfinProgramStream extends ProgramStream { : undefined; } - const sourcePath = stream.streamDetails.directFilePath; - let baseNameExt = basename(sourcePath) - let subtitlesPath: Nullable = null; - - if (sourcePath) { - const sourceDir = dirname(sourcePath); - console.log(`Source Directory: ${sourceDir}`); - - try { - const baseName = baseNameExt.replace(/\.[^/.]+$/, ''); // Strip the extension - console.log(`Base Name: ${baseName}`); - - // List all files in the directory - const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName("JF"); - const adjacentItems = await jellyfinClient.getDirectoryContents(sourceDir); - - console.log(adjacentItems); - - // Filter for subtitles: match file names (without extensions) and look for .srt files - const potentialSubtitles = adjacentItems.filter((fileName) => - fileName.startsWith(baseName) && fileName.endsWith('.srt') - ); - - console.log("Potential Subtitles:", potentialSubtitles); - - // Use the first match, if available - if (potentialSubtitles.length > 0) { - subtitlesPath = join(sourceDir, potentialSubtitles[0]); // Combine directory with file name - } - } catch (err) { - console.error(`Failed to read directory: ${sourceDir}`, err); - } - } - - console.log(`Subtitles Path: ${subtitlesPath}`); - + // set up server for api calls + const jellyfinClient = await MediaSourceApiFactory().getJellyfinByName("JF"); + const serverPath = stream.streamDetails.serverPath + console.log(`Server Path: ${serverPath}`) + // get item details + const itemDetails = await jellyfinClient?.getItems( + null, + null, + null, + [], + null, + {ids: [serverPath]} + ); + + let hasSubs = false + if (itemDetails?.data?.Items?.[0]?.HasSubtitles) { + hasSubs = true + }; + console.log(`Has Subs? ${hasSubs}`); + const start = dayjs.duration(lineupItem.startOffset ?? 0); @@ -150,7 +132,7 @@ export class JellyfinProgramStream extends ProgramStream { ? dayjs.duration(lineupItem.duration) : dayjs.duration(lineupItem.streamDuration ?? lineupItem.duration), watermark, - subtitles: null, + subtitles: hasSubs ?? false, realtime: this.context.realtime, extraInputHeaders: {}, outputFormat: this.outputFormat, diff --git a/server/src/stream/types.ts b/server/src/stream/types.ts index 0a519751b..cb60090b1 100644 --- a/server/src/stream/types.ts +++ b/server/src/stream/types.ts @@ -17,13 +17,14 @@ export type StreamDetails = { bitrate?: number; // If defined, there is at least one video stream - videoDetails?: [VideoStreamDetails, ...VideoStreamDetails[]]; - audioDetails?: [AudioStreamDetails, ...AudioStreamDetails[]]; + videoDetails?: [VideoStreamDetails, ...VideoStreamDetails[]]; + audioDetails?: [AudioStreamDetails, ...AudioStreamDetails[]]; audioOnly?: boolean; placeholderImage?: string; serverPath?: string; directFilePath?: string; + // hasSubtitles?: boolean; }; export type VideoStreamDetails = { diff --git a/web/src/components/Video.tsx b/web/src/components/Video.tsx index 2b82d5e02..d0b4d2184 100644 --- a/web/src/components/Video.tsx +++ b/web/src/components/Video.tsx @@ -13,6 +13,7 @@ import { useSettings } from '../store/settings/selectors.ts'; type VideoProps = { channelId: string; + subtitles?: string; }; export default function Video({ channelId }: VideoProps) { @@ -115,7 +116,15 @@ export default function Video({ channelId }: VideoProps) { return ( -