tunarr/server/src/stream/jellyfin/JellyfinProgramStream.ts
2025-09-07 11:15:46 -04:00

137 lines
4.4 KiB
TypeScript

import { isJellyfinBackedLineupItem } from '@/db/derived_types/StreamLineup.js';
import { type ISettingsDB } from '@/db/interfaces/ISettingsDB.js';
import { type MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { type FfmpegTranscodeSession } from '@/ffmpeg/FfmpegTrancodeSession.js';
import { type OutputFormat } from '@/ffmpeg/builder/constants.js';
import type { StreamOptions } from '@/ffmpeg/ffmpegBase.js';
import { type IFFMPEG } from '@/ffmpeg/ffmpegBase.js';
import { type CacheImageService } from '@/services/cacheImageService.js';
import { type PlayerContext } from '@/stream/PlayerStreamContext.js';
import { ProgramStream } from '@/stream/ProgramStream.js';
import { type UpdateJellyfinPlayStatusScheduledTask } from '@/tasks/jellyfin/UpdateJellyfinPlayStatusTask.js';
import { Result } from '@/types/result.js';
import { type Maybe, type Nullable } from '@/types/util.js';
import { ifDefined } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import dayjs from 'dayjs';
import { type interfaces } from 'inversify';
import { isNil, isNull, isUndefined } from 'lodash-es';
import { type FFmpegFactory } from '../../ffmpeg/FFmpegModule.js';
import { type JellyfinStreamDetails } from './JellyfinStreamDetails.js';
export class JellyfinProgramStream extends ProgramStream {
protected logger = LoggerFactory.child({
caller: import.meta,
className: JellyfinProgramStream.name,
});
private ffmpeg: Nullable<IFFMPEG> = null;
private killed: boolean = false;
private updatePlayStatusTask: Maybe<UpdateJellyfinPlayStatusScheduledTask>;
constructor(
settingsDB: ISettingsDB,
private mediaSourceDB: MediaSourceDB,
private streamDetailsFactory: interfaces.AutoFactory<JellyfinStreamDetails>,
cacheImageService: CacheImageService,
ffmpegFactory: FFmpegFactory,
context: PlayerContext,
outputFormat: OutputFormat,
) {
super(context, outputFormat, settingsDB, cacheImageService, ffmpegFactory);
}
protected shutdownInternal() {
this.killed = true;
ifDefined(this.updatePlayStatusTask, (task) => {
task.stop();
});
}
async setupInternal(
opts?: StreamOptions,
): Promise<Result<FfmpegTranscodeSession>> {
const lineupItem = this.context.lineupItem;
if (!isJellyfinBackedLineupItem(lineupItem)) {
return Result.failure(
new Error(
'Lineup item is not backed by a media source: ' +
JSON.stringify(lineupItem),
),
);
}
const server = await this.mediaSourceDB.findByType(
MediaSourceType.Jellyfin,
lineupItem.externalSourceId,
);
if (isNil(server)) {
return Result.failure(
new Error(
`Unable to find server "${lineupItem.externalSourceId}" specified by program.`,
),
);
}
const jellyfinStreamDetails = this.streamDetailsFactory();
const watermark = await this.getWatermark();
this.ffmpeg = this.ffmpegFactory(
this.context.transcodeConfig,
this.context.sourceChannel,
this.context.streamMode,
);
const stream = await jellyfinStreamDetails.getStream({
server,
lineupItem: {
...lineupItem,
externalFilePath: lineupItem.plexFilePath ?? undefined,
},
});
if (isNull(stream)) {
return Result.failure(
new Error('Unable to retrieve stream details from Jellyfin'),
);
}
if (this.killed) {
return Result.failure(new Error('Stream was killed already, returning'));
}
const streamStats = stream.streamDetails;
if (streamStats) {
streamStats.duration = lineupItem.streamDuration
? dayjs.duration(lineupItem.streamDuration)
: undefined;
}
const start = dayjs.duration(lineupItem.startOffset ?? 0);
const ffmpegOutStream = await this.ffmpeg.createStreamSession({
stream: {
source: stream.streamSource,
details: stream.streamDetails,
},
options: {
startTime: start,
duration: dayjs.duration(lineupItem.streamDuration),
watermark,
realtime: this.context.realtime,
extraInputHeaders: {},
outputFormat: this.outputFormat,
streamMode: this.context.streamMode,
...(opts ?? {}),
},
lineupItem,
});
if (isUndefined(ffmpegOutStream)) {
return Result.failure(new Error('Unable to spawn ffmpeg'));
}
return Result.success(ffmpegOutStream);
}
}