From b9a0ab779c8c89a39048e9a615cfb18f4acd8ee1 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Mon, 27 May 2024 12:32:48 -0400 Subject: [PATCH] Remove Plex Transcoder. Fixes #441 (#452) * remove plex transcoder checkpoint * Forgot to push this file * Remove usages of plex transcoder on backend. Forces direct play and allows Tunarr to own all normalization logic * Capitalizing class files * Remove plex transcode settings from UI --- server/src/api/channelsApi.ts | 2 +- server/src/api/debugApi.ts | 2 +- server/src/api/index.ts | 2 +- server/src/api/plexServersApi.ts | 2 +- server/src/api/videoApi.ts | 2 +- server/src/api/xmltvSettingsApi.ts | 2 +- server/src/dao/derived_types/StreamLineup.ts | 3 + server/src/external/plex.ts | 58 +- server/src/ffmpeg/ffmpeg.ts | 15 +- server/src/server.ts | 2 +- server/src/services/scheduler.ts | 6 +- server/src/stream/StreamProgramCalculator.ts | 7 +- .../plex/{plexPlayer.ts => PlexPlayer.ts} | 141 ++--- server/src/stream/plex/PlexStreamDetails.ts | 181 ++++++ server/src/stream/plex/plexTranscoder.ts | 26 +- server/src/stream/programPlayer.ts | 2 +- ...SessionsTask.ts => CleanupSessionsTask.ts} | 0 ...Task.ts => ScheduleDynamicChannelsTask.ts} | 0 server/src/tasks/ScheduledTask.ts | 3 +- server/src/tasks/UpdatePlexPlayStatusTask.ts | 133 +++++ ...{updateXmlTvTask.ts => UpdateXmlTvTask.ts} | 0 types/src/plex/index.ts | 88 +++ web/src/pages/settings/PlexSettingsPage.tsx | 515 +----------------- 23 files changed, 590 insertions(+), 602 deletions(-) rename server/src/stream/plex/{plexPlayer.ts => PlexPlayer.ts} (55%) create mode 100644 server/src/stream/plex/PlexStreamDetails.ts rename server/src/tasks/{cleanupSessionsTask.ts => CleanupSessionsTask.ts} (100%) rename server/src/tasks/{scheduleDynamicChannelsTask.ts => ScheduleDynamicChannelsTask.ts} (100%) create mode 100644 server/src/tasks/UpdatePlexPlayStatusTask.ts rename server/src/tasks/{updateXmlTvTask.ts => UpdateXmlTvTask.ts} (100%) diff --git a/server/src/api/channelsApi.ts b/server/src/api/channelsApi.ts index 68ca36a4..3f4af4b0 100644 --- a/server/src/api/channelsApi.ts +++ b/server/src/api/channelsApi.ts @@ -18,7 +18,7 @@ import duration from 'dayjs/plugin/duration.js'; import { compact, isError, isNil, map, omit, sortBy } from 'lodash-es'; import z from 'zod'; import { GlobalScheduler } from '../services/scheduler.js'; -import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js'; +import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { attempt, mapAsyncSeq } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; diff --git a/server/src/api/debugApi.ts b/server/src/api/debugApi.ts index 11862904..06323651 100644 --- a/server/src/api/debugApi.ts +++ b/server/src/api/debugApi.ts @@ -12,7 +12,7 @@ import { isContentBackedLineupIteam, } from '../dao/derived_types/StreamLineup.js'; import { Channel } from '../dao/entities/Channel.js'; -import { PlexPlayer } from '../stream/plex/plexPlayer.js'; +import { PlexPlayer } from '../stream/plex/PlexPlayer.js'; import { PlexTranscoder } from '../stream/plex/plexTranscoder.js'; import { FillerPicker } from '../services/FillerPicker.js'; import { StreamContextChannel } from '../stream/types.js'; diff --git a/server/src/api/index.ts b/server/src/api/index.ts index 4e3fc2e1..f064dd51 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -10,7 +10,7 @@ import { Plex } from '../external/plex.js'; import { FFMPEGInfo } from '../ffmpeg/ffmpegInfo.js'; import { serverOptions } from '../globals.js'; import { GlobalScheduler } from '../services/scheduler.js'; -import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js'; +import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { fileExists } from '../util/fsUtil.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; diff --git a/server/src/api/plexServersApi.ts b/server/src/api/plexServersApi.ts index 3a92161b..d2d96d57 100644 --- a/server/src/api/plexServersApi.ts +++ b/server/src/api/plexServersApi.ts @@ -10,7 +10,7 @@ import z from 'zod'; import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js'; import { Plex, PlexApiFactory } from '../external/plex.js'; import { GlobalScheduler } from '../services/scheduler.js'; -import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js'; +import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginAsyncCallback } from '../types/serverType.js'; import { firstDefined, wait } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; diff --git a/server/src/api/videoApi.ts b/server/src/api/videoApi.ts index 6ca0ed71..60f5eebc 100644 --- a/server/src/api/videoApi.ts +++ b/server/src/api/videoApi.ts @@ -224,7 +224,7 @@ export const videoRouter: RouterPluginAsyncCallback = async (fastify) => { querystring: StreamQueryStringSchema, }, onError(req, _, e) { - logger.error('Error on /stream: %s. %O', req.raw.url, e); + logger.error(e, 'Error on /stream: %s. %O', req.raw.url); }, }, async (req, res) => { diff --git a/server/src/api/xmltvSettingsApi.ts b/server/src/api/xmltvSettingsApi.ts index 3be3121b..96bd2234 100644 --- a/server/src/api/xmltvSettingsApi.ts +++ b/server/src/api/xmltvSettingsApi.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { defaultXmlTvSettings } from '../dao/settings.js'; import { serverOptions } from '../globals.js'; import { GlobalScheduler } from '../services/scheduler.js'; -import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js'; +import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { RouterPluginCallback } from '../types/serverType.js'; import { firstDefined } from '../util/index.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; diff --git a/server/src/dao/derived_types/StreamLineup.ts b/server/src/dao/derived_types/StreamLineup.ts index 4f84be85..c2204522 100644 --- a/server/src/dao/derived_types/StreamLineup.ts +++ b/server/src/dao/derived_types/StreamLineup.ts @@ -59,6 +59,8 @@ export type LoadingStreamLineupItem = z.infer< typeof LoadingStreamLineupItemSchema >; +const ProgramTypeEnum = z.enum(['movie', 'episode', 'track']); + const BaseContentBackedStreamLineupItemSchema = baseStreamLineupItemSchema.extend({ programId: z.string().uuid(), @@ -67,6 +69,7 @@ const BaseContentBackedStreamLineupItemSchema = externalSourceId: z.string(), filePath: z.string(), externalKey: z.string(), + programType: ProgramTypeEnum, }); const CommercialStreamLineupItemSchema = diff --git a/server/src/external/plex.ts b/server/src/external/plex.ts index 80b39c87..c98d326f 100644 --- a/server/src/external/plex.ts +++ b/server/src/external/plex.ts @@ -1,6 +1,12 @@ import { EntityDTO } from '@mikro-orm/core'; import { DefaultPlexHeaders } from '@tunarr/shared/constants'; -import { PlexDvr, PlexDvrsResponse, PlexResource } from '@tunarr/types/plex'; +import { + PlexDvr, + PlexDvrsResponse, + PlexMedia, + PlexMediaContainerResponseSchema, + PlexResource, +} from '@tunarr/types/plex'; import axios, { AxiosInstance, AxiosRequestConfig, @@ -9,7 +15,7 @@ import axios, { isAxiosError, } from 'axios'; import { XMLParser } from 'fast-xml-parser'; -import { flatMap, forEach, isNil, isUndefined, map } from 'lodash-es'; +import { first, flatMap, forEach, isNil, isUndefined, map } from 'lodash-es'; import NodeCache from 'node-cache'; import querystring, { ParsedUrlQueryInput } from 'querystring'; import { MarkOptional } from 'ts-essentials'; @@ -20,6 +26,7 @@ import { } from '../types/plexApiTypes.js'; import { Maybe } from '../types/util.js'; import { Logger, LoggerFactory } from '../util/logging/LoggerFactory.js'; +import { z } from 'zod'; type AxiosConfigWithMetadata = InternalAxiosRequestConfig & { metadata: { @@ -162,7 +169,7 @@ export class Plex { }; if (this.accessToken === '') { - throw Error( + throw new Error( 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', ); } @@ -171,9 +178,54 @@ export class Plex { if (!res?.MediaContainer) { this.logger.error(res, 'Expected MediaContainer, got %O'); } + return res?.MediaContainer; } + async doTypeCheckedGet>( + path: string, + schema: T, + optionalHeaders: RawAxiosRequestHeaders = {}, + ): Promise> { + const req: AxiosRequestConfig = { + method: 'get', + url: path, + headers: optionalHeaders, + }; + + if (this.accessToken === '') { + throw new Error( + 'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.', + ); + } + + const response = await this.doRequest(req); + + const parsed = await schema.safeParseAsync(response); + + if (parsed.success) { + return parsed.data as Out; + } + + this.logger.error( + parsed.error, + 'Unable to parse schema from Plex response. Path: %s', + path, + ); + return; + } + + async getItemMetadata(key: string): Promise> { + const parsedResponse = await this.doTypeCheckedGet( + `/library/metadata/${key}`, + PlexMediaContainerResponseSchema, + ); + if (!isUndefined(parsedResponse)) { + return first(parsedResponse.MediaContainer.Metadata); + } + return; + } + doPut( path: string, query: ParsedUrlQueryInput | URLSearchParams = {}, diff --git a/server/src/ffmpeg/ffmpeg.ts b/server/src/ffmpeg/ffmpeg.ts index cf662997..8c7182cf 100644 --- a/server/src/ffmpeg/ffmpeg.ts +++ b/server/src/ffmpeg/ffmpeg.ts @@ -5,7 +5,7 @@ import { isEmpty, isNil, isString, isUndefined, merge, round } from 'lodash-es'; import path from 'path'; import { DeepReadonly, DeepRequired } from 'ts-essentials'; import { serverOptions } from '../globals.js'; -import { VideoStats } from '../stream/plex/plexTranscoder.js'; +import { StreamDetails } from '../stream/plex/plexTranscoder.js'; import { StreamContextChannel } from '../stream/types.js'; import { Maybe } from '../types/util.js'; import { TypedEventEmitter } from '../types/eventEmitter.js'; @@ -310,12 +310,10 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< spawnStream( streamUrl: string, - streamStats: Maybe, + streamStats: Maybe, startTime: Maybe, duration: Maybe, enableIcon: Maybe, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _: string, //LineupItem[type] ) { return this.spawn( streamUrl, @@ -342,7 +340,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< duration = MAXIMUM_ERROR_DURATION_MS; } duration = Math.min(MAXIMUM_ERROR_DURATION_MS, duration); - const streamStats: VideoStats = { + const streamStats: StreamDetails = { videoWidth: this.wantedW, videoHeight: this.wantedH, duration: duration, @@ -376,7 +374,7 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< spawn( streamUrl: string | { errorTitle: string; subtitle?: string }, - streamStats: Maybe, + streamStats: Maybe, startTime: Maybe, duration: Maybe, limitRead: boolean, @@ -566,7 +564,12 @@ export class FFMPEG extends (events.EventEmitter as new () => TypedEventEmitter< currentAudio = '[audiox]'; } currentVideo = '[videox]'; + } else { + // HACK: We know these will be defined already if we get this far + iW = iW!; + iH = iH!; } + if (doOverlay && !isNil(watermark?.url)) { if (watermark.animated) { ffmpegArgs.push('-ignore_loop', '0'); diff --git a/server/src/server.ts b/server/src/server.ts index 71165016..601080d2 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -31,7 +31,7 @@ import { ServerRequestContext, serverContext } from './serverContext.js'; import { GlobalScheduler, scheduleJobs } from './services/scheduler.js'; import { initPersistentStreamCache } from './stream/channelCache.js'; import { runFixers } from './tasks/fixers/index.js'; -import { UpdateXmlTvTask } from './tasks/updateXmlTvTask.js'; +import { UpdateXmlTvTask } from './tasks/UpdateXmlTvTask.js'; import { filename, isProduction, run } from './util/index.js'; import { LoggerFactory } from './util/logging/LoggerFactory.js'; diff --git a/server/src/services/scheduler.ts b/server/src/services/scheduler.ts index 6a8e5343..46a3a892 100644 --- a/server/src/services/scheduler.ts +++ b/server/src/services/scheduler.ts @@ -7,9 +7,9 @@ import { OneOffTask } from '../tasks/OneOffTask.js'; import { ReconcileProgramDurationsTask } from '../tasks/ReconcileProgramDurationsTask.js'; import { ScheduledTask } from '../tasks/ScheduledTask.js'; import { Task, TaskId } from '../tasks/Task.js'; -import { CleanupSessionsTask } from '../tasks/cleanupSessionsTask.js'; -import { ScheduleDynamicChannelsTask } from '../tasks/scheduleDynamicChannelsTask.js'; -import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js'; +import { CleanupSessionsTask } from '../tasks/CleanupSessionsTask.js'; +import { ScheduleDynamicChannelsTask } from '../tasks/ScheduleDynamicChannelsTask.js'; +import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js'; import { typedProperty } from '../types/path.js'; import { Maybe } from '../types/util.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; diff --git a/server/src/stream/StreamProgramCalculator.ts b/server/src/stream/StreamProgramCalculator.ts index 26a6e479..49a64ff1 100644 --- a/server/src/stream/StreamProgramCalculator.ts +++ b/server/src/stream/StreamProgramCalculator.ts @@ -34,10 +34,7 @@ export type ProgramAndTimeElapsed = { }; function programHasRequiredStreamingFields(program: Loaded) { - return every( - [program.plexFilePath, program.plexRatingKey, program.filePath], - negate(isNil), - ); + return every([program.plexFilePath, program.filePath], negate(isNil)); } // any channel thing used here should be added to channel context @@ -161,6 +158,7 @@ export class StreamProgramCalculator { programId: backingItem.uuid, title: backingItem.title, id: backingItem.uuid, + programType: backingItem.type, }; } } else if (isOfflineItem(lineupItem)) { @@ -289,6 +287,7 @@ export class StreamProgramCalculator { beginningOffset: beginningOffset, externalSourceId: filler.externalSourceId, plexFilePath: filler.plexFilePath!, + programType: filler.type, }; } // pick the offline screen diff --git a/server/src/stream/plex/plexPlayer.ts b/server/src/stream/plex/PlexPlayer.ts similarity index 55% rename from server/src/stream/plex/plexPlayer.ts rename to server/src/stream/plex/PlexPlayer.ts index 8aae5a11..1c647484 100644 --- a/server/src/stream/plex/plexPlayer.ts +++ b/server/src/stream/plex/PlexPlayer.ts @@ -1,37 +1,31 @@ -/****************** - * This module has to follow the program-player contract. - * Async call to get a stream. - * * If connection to plex or the file entry fails completely before playing - * it rejects the promise and the error is an Error() class. - * * Otherwise it returns a stream. - **/ import constants from '@tunarr/shared/constants'; import EventEmitter from 'events'; -import { isNil, isUndefined } from 'lodash-es'; +import { isNil, isNull, isUndefined } from 'lodash-es'; import { Writable } from 'stream'; import { isContentBackedLineupIteam } from '../../dao/derived_types/StreamLineup.js'; import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js'; import { FFMPEG, FfmpegEvents } from '../../ffmpeg/ffmpeg.js'; +import { GlobalScheduler } from '../../services/scheduler.js'; +import { UpdatePlexPlayStatusScheduledTask } from '../../tasks/UpdatePlexPlayStatusTask.js'; import { TypedEventEmitter } from '../../types/eventEmitter.js'; +import { Maybe, Nullable } from '../../types/util.js'; +import { ifDefined } from '../../util/index.js'; import { LoggerFactory } from '../../util/logging/LoggerFactory.js'; import { Player, PlayerContext } from '../player.js'; -import { PlexTranscoder } from './plexTranscoder.js'; +import { PlexStreamDetails } from './PlexStreamDetails.js'; const USED_CLIENTS: Record = {}; + export class PlexPlayer extends Player { private logger = LoggerFactory.child({ caller: import.meta }); - private context: PlayerContext; - private ffmpeg: FFMPEG | null; - private plexTranscoder: PlexTranscoder | null; - private killed: boolean; + private ffmpeg: Nullable = null; + private killed: boolean = false; private clientId: string; + private updatePlexStatusTask: Maybe; - constructor(context: PlayerContext) { + constructor(private context: PlayerContext) { super(); - this.context = context; - this.ffmpeg = null; - this.plexTranscoder = null; - this.killed = false; + // TODO: Is this even useful?? const coreClientId = this.context.settings.clientId(); let i = 0; while (USED_CLIENTS[coreClientId + '-' + i] === true) { @@ -45,25 +39,26 @@ export class PlexPlayer extends Player { super.cleanUp(); USED_CLIENTS[this.clientId] = false; this.killed = true; - if (this.plexTranscoder != null) { - this.plexTranscoder.stopUpdatingPlex().catch((e) => { - this.logger.error(e, 'Error stopping Plex status updates'); - }); - this.plexTranscoder = null; - } - if (this.ffmpeg != null) { + ifDefined(this.updatePlexStatusTask, (task) => { + task.stop(); + }); + + if (this.ffmpeg !== null) { this.ffmpeg.kill(); this.ffmpeg = null; } } - async play(outStream: Writable) { + async play( + outStream: Writable, + ): Promise | undefined> { const lineupItem = this.context.lineupItem; if (!isContentBackedLineupIteam(lineupItem)) { throw new Error( 'Lineup item is not backed by Plex: ' + JSON.stringify(lineupItem), ); } + const ffmpegSettings = this.context.ffmpegSettings; const db = this.context.entityManager.repo(PlexServerSettings); const channel = this.context.channel; @@ -73,24 +68,23 @@ export class PlexPlayer extends Player { `Unable to find server "${lineupItem.externalSourceId}" specified by program.`, ); } + if (server.uri.endsWith('/')) { server.uri = server.uri.slice(0, server.uri.length - 1); } const plexSettings = this.context.settings.plexSettings(); - const plexTranscoder = new PlexTranscoder( - this.clientId, + const plexStreamDetails = new PlexStreamDetails( server, - plexSettings, - channel, - lineupItem, + this.context.settings, ); - this.plexTranscoder = plexTranscoder; + const watermark = this.context.watermark; - let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options - ffmpeg.setAudioOnly(this.context.audioOnly); - this.ffmpeg = ffmpeg; + this.ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + this.ffmpeg.setAudioOnly(this.context.audioOnly); + let streamDuration: number | undefined; + if ( !isUndefined(lineupItem.streamDuration) && (lineupItem.start ?? 0) + lineupItem.streamDuration + constants.SLACK < @@ -99,80 +93,93 @@ export class PlexPlayer extends Player { streamDuration = lineupItem.streamDuration / 1000; } - const stream = await plexTranscoder.getStream(/* deinterlace=*/ true); + const stream = await plexStreamDetails.getStream(lineupItem); + if (isNull(stream)) { + this.logger.error('Unable to retrieve stream details from Plex'); + return; + } + if (this.killed) { + this.logger.warn('Plex stream was killed already, returning'); return; } - //let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; - //let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : lineupItem.start; - const streamStart = stream.directPlay - ? plexTranscoder.currTimeS - : undefined; - const streamStats = stream.streamStats; + const streamStart = (lineupItem.start ?? 0) / 1000; + const streamStats = stream.streamDetails; if (streamStats) { streamStats.duration = lineupItem.streamDuration; } const emitter = new EventEmitter() as TypedEventEmitter; - let ff = ffmpeg.spawnStream( + let ffmpegOutStream = this.ffmpeg.spawnStream( stream.streamUrl, - stream.streamStats, + stream.streamDetails, streamStart, streamDuration?.toString(), watermark, - lineupItem.type, ); // Spawn the ffmpeg process - if (isUndefined(ff)) { + if (isUndefined(ffmpegOutStream)) { throw new Error('Unable to spawn ffmpeg'); } - ff.pipe(outStream, { end: false }); - plexTranscoder.startUpdatingPlex().catch((e) => { - this.logger.error(e, 'Error starting Plex status updates'); - }); + ffmpegOutStream.pipe(outStream, { end: false }); + + if (plexSettings.updatePlayStatus) { + this.updatePlexStatusTask = new UpdatePlexPlayStatusScheduledTask( + server, + { + channelNumber: channel.number, + duration: lineupItem.duration, + ratingKey: lineupItem.externalKey, + startTime: lineupItem.start ?? 0, + }, + ); + + GlobalScheduler.scheduleTask( + this.updatePlexStatusTask.id, + this.updatePlexStatusTask, + ); + } - ffmpeg.on('end', () => { + this.ffmpeg.on('end', () => { this.logger.trace('ffmpeg end'); emitter.emit('end'); }); - ffmpeg.on('close', () => { + this.ffmpeg.on('close', () => { this.logger.trace('ffmpeg close'); emitter.emit('close'); }); // eslint-disable-next-line @typescript-eslint/no-misused-promises - ffmpeg.on('error', (err) => { - console.log('Replacing failed stream with error stream'); - ff!.unpipe(outStream); - // ffmpeg.removeAllListeners('data'); Type inference says this doesnt ever exist - ffmpeg.removeAllListeners('end'); - ffmpeg.removeAllListeners('error'); - ffmpeg.removeAllListeners('close'); - ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options - ffmpeg.setAudioOnly(this.context.audioOnly); - ffmpeg.on('close', () => { + this.ffmpeg.on('error', (err) => { + this.logger.debug('Replacing failed stream with error stream'); + ffmpegOutStream!.unpipe(outStream); + this.ffmpeg?.removeAllListeners(); + // TODO: Extremely weird logic here leftover, should sort this all out. + this.ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + this.ffmpeg.setAudioOnly(this.context.audioOnly); + this.ffmpeg.on('close', () => { emitter.emit('close'); }); - ffmpeg.on('end', () => { + this.ffmpeg.on('end', () => { emitter.emit('end'); }); - ffmpeg.on('error', (err) => { + this.ffmpeg.on('error', (err) => { emitter.emit('error', err); }); try { - ff = ffmpeg.spawnError( + ffmpegOutStream = this.ffmpeg.spawnError( 'oops', 'oops', Math.min(streamStats?.duration ?? 30000, 60000), ); - if (isUndefined(ff)) { + if (isUndefined(ffmpegOutStream)) { throw new Error('Unable to spawn ffmpeg...what is going on here'); } - ff.pipe(outStream); + ffmpegOutStream.pipe(outStream); } catch (err) { this.logger.error(err, 'Err while trying to spawn error stream! YIKES'); } diff --git a/server/src/stream/plex/PlexStreamDetails.ts b/server/src/stream/plex/PlexStreamDetails.ts new file mode 100644 index 00000000..2c34677a --- /dev/null +++ b/server/src/stream/plex/PlexStreamDetails.ts @@ -0,0 +1,181 @@ +import { + PlexEpisode, + PlexMediaAudioStream, + PlexMediaVideoStream, + PlexMovie, + PlexMusicTrack, +} from '@tunarr/types/plex'; +import { + find, + first, + indexOf, + isNull, + isUndefined, + replace, + trimEnd, +} from 'lodash-es'; +import { PlexServerSettings } from '../../dao/entities/PlexServerSettings'; +import { Plex, PlexApiFactory } from '../../external/plex'; +import { Nullable } from '../../types/util'; +import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory'; +import { PlexStream, StreamDetails } from './plexTranscoder'; +import { isNonEmptyString } from '../../util'; +import { serverOptions } from '../../globals'; +import { ContentBackedStreamLineupItem } from '../../dao/derived_types/StreamLineup.js'; +import { SettingsDB } from '../../dao/settings.js'; + +// The minimum fields we need to get stream details about an item +type PlexItemStreamDetailsQuery = Pick< + ContentBackedStreamLineupItem, + 'programType' | 'externalKey' | 'plexFilePath' | 'filePath' +>; + +/** + * A 'new' version of the PlexTranscoder class that does not + * invoke the transcoding on Plex at all. Instead, it gathers stream + * metadata through standard Plex endpoints and always "direct plays" items, + * leaving normalization up to the Tunarr FFMPEG pipeline. + */ +export class PlexStreamDetails { + private logger: Logger; + + constructor( + private server: PlexServerSettings, + private settings: SettingsDB, + ) { + this.logger = LoggerFactory.child({ + plexServer: server.name, + // channel: channel.uuid, + caller: import.meta, + }); + } + + async getStream( + item: PlexItemStreamDetailsQuery, + ): Promise> { + const plex = PlexApiFactory.get(this.server); + const expectedItemType = item.programType; + const itemMetadata = await plex.getItemMetadata(item.externalKey); + + if (isUndefined(itemMetadata)) { + // This will have to throw somewhere! + return null; + } + + if (expectedItemType !== itemMetadata.type) { + this.logger.warn( + 'Got unexpected item type %s from Plex (ID = %s) when starting stream. Expected item type %s', + itemMetadata.type, + item.externalKey, + expectedItemType, + ); + return null; + } + + const details = this.getItemStreamDetails(item, itemMetadata); + + if (isNull(details)) { + return null; + } + + const streamSettings = this.settings.plexSettings(); + + let streamUrl: string; + const filePath = first(first(itemMetadata.Media)?.Part)?.file; + if (streamSettings.streamPath === 'direct' && isNonEmptyString(filePath)) { + streamUrl = replace( + filePath, + streamSettings.pathReplace, + streamSettings.pathReplaceWith, + ); + } else { + const path = item.plexFilePath.startsWith('/') + ? item.plexFilePath + : `/${item.plexFilePath}`; + streamUrl = `${trimEnd(this.server.uri, '/')}${path}?X-Plex-Token=${ + this.server.accessToken + }`; + } + + return { + directPlay: true, + streamUrl, + streamDetails: details, + }; + } + + private getItemStreamDetails( + item: PlexItemStreamDetailsQuery, + media: PlexMovie | PlexEpisode | PlexMusicTrack, + ): Nullable { + const streamDetails: StreamDetails = {}; + const firstStream = first(first(media.Media)?.Part)?.Stream; + if (isUndefined(firstStream)) { + this.logger.error( + 'Could not extract a stream for Plex item ID = %s', + item.externalKey, + ); + } + + const videoStream = find( + firstStream, + (stream): stream is PlexMediaVideoStream => stream.streamType === 1, + ); + + const audioStream = find( + firstStream, + (stream): stream is PlexMediaAudioStream => + stream.streamType === 2 && !!stream.selected, + ); + const audioOnly = isUndefined(videoStream) && !isUndefined(audioStream); + + // Video + if (!isUndefined(videoStream)) { + // TODO Parse pixel aspect ratio + streamDetails.anamorphic = + videoStream.anamorphic === '1' || videoStream.anamorphic === true; + streamDetails.videoCodec = videoStream.codec; + // Keeping old behavior here for now + streamDetails.videoFramerate = Math.round(videoStream.frameRate); + streamDetails.videoHeight = videoStream.height; + streamDetails.videoWidth = videoStream.width; + streamDetails.videoBitDepth = videoStream.bitDepth; + streamDetails.pixelP = 1; + streamDetails.pixelQ = 1; + } + + if (!isUndefined(audioStream)) { + streamDetails.audioChannels = audioStream.channels; + streamDetails.audioCodec = audioStream.codec; + // TODO: I dont love calling indexOf when we already searched for this + // stream in the list in the first place + streamDetails.audioIndex = indexOf(firstStream, audioStream)?.toString(); + } + + if (isUndefined(videoStream) && isUndefined(audioStream)) { + this.logger.warn( + 'Could not find a video nor audio stream for Plex item %s', + item.externalKey, + ); + return null; + } + + if (audioOnly) { + // TODO Use our proxy endpoint here + streamDetails.placeholderImage = Plex.getThumbUrl({ + ...this.server, + itemKey: item.externalKey, + }); + + if (!isNonEmptyString(streamDetails.placeholderImage)) { + streamDetails.placeholderImage = `http://localhost:${ + serverOptions().port + }/images/generic-music-screen.png`; + } + } + + streamDetails.audioOnly = audioOnly; + + return streamDetails; + } +} diff --git a/server/src/stream/plex/plexTranscoder.ts b/server/src/stream/plex/plexTranscoder.ts index 666a0b40..10219b94 100644 --- a/server/src/stream/plex/plexTranscoder.ts +++ b/server/src/stream/plex/plexTranscoder.ts @@ -21,30 +21,34 @@ import { } from '../../types/plexApiTypes.js'; import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory.js'; -type PlexStream = { +export type PlexStream = { directPlay: boolean; streamUrl: string; separateVideoStream?: string; - streamStats?: VideoStats; + streamDetails?: StreamDetails; }; -export type VideoStats = { +export type StreamDetails = { duration?: number; anamorphic?: boolean; pixelP?: number; pixelQ?: number; + videoCodec?: string; - videoWidth: number; - videoHeight: number; + videoWidth?: number; + videoHeight?: number; videoFramerate?: number; videoDecision?: string; - audioDecision?: string; videoScanType?: string; + videoBitDepth?: number; + + audioDecision?: string; audioOnly?: boolean; audioChannels?: number; audioCodec?: string; - placeholderImage?: string; audioIndex?: string; + + placeholderImage?: string; }; export class PlexTranscoder { @@ -225,7 +229,7 @@ export class PlexTranscoder { directPlay, streamUrl, separateVideoStream, - streamStats, + streamDetails: streamStats, }; this.log('PlexStream: %O', stream); @@ -429,8 +433,8 @@ lang=en`; } // TODO - cache this somehow so we only update VideoStats if decisionJson or directInfo change - getVideoStats(): VideoStats { - const ret: Partial = {}; + getVideoStats(): StreamDetails { + const ret: Partial = {}; try { const streams: TranscodeDecisionMediaStream[] = @@ -499,7 +503,7 @@ lang=en`; this.log('Current video stats: %O', ret); - return ret as Required; // This isn't technically right, but this is how the current code treats this + return ret as Required; // This isn't technically right, but this is how the current code treats this } private async getAudioIndex() { diff --git a/server/src/stream/programPlayer.ts b/server/src/stream/programPlayer.ts index 33e7e0cf..7afbac05 100644 --- a/server/src/stream/programPlayer.ts +++ b/server/src/stream/programPlayer.ts @@ -28,7 +28,7 @@ import { Maybe } from '../types/util.js'; import { isNonEmptyString } from '../util/index.js'; import { OfflinePlayer } from './offlinePlayer.js'; import { Player, PlayerContext } from './player.js'; -import { PlexPlayer } from './plex/plexPlayer.js'; +import { PlexPlayer } from './plex/PlexPlayer.js'; import { StreamContextChannel } from './types.js'; import { LoggerFactory } from '../util/logging/LoggerFactory.js'; diff --git a/server/src/tasks/cleanupSessionsTask.ts b/server/src/tasks/CleanupSessionsTask.ts similarity index 100% rename from server/src/tasks/cleanupSessionsTask.ts rename to server/src/tasks/CleanupSessionsTask.ts diff --git a/server/src/tasks/scheduleDynamicChannelsTask.ts b/server/src/tasks/ScheduleDynamicChannelsTask.ts similarity index 100% rename from server/src/tasks/scheduleDynamicChannelsTask.ts rename to server/src/tasks/ScheduleDynamicChannelsTask.ts diff --git a/server/src/tasks/ScheduledTask.ts b/server/src/tasks/ScheduledTask.ts index 8c9ee211..6c3853b1 100644 --- a/server/src/tasks/ScheduledTask.ts +++ b/server/src/tasks/ScheduledTask.ts @@ -17,8 +17,9 @@ type ScheduledTaskOptions = { export class ScheduledTask { protected logger: Logger; + protected scheduledJob: schedule.Job; + private factory: TaskFactoryFn; - private scheduledJob: schedule.Job; private schedule: ScheduleRule; public running: boolean = false; diff --git a/server/src/tasks/UpdatePlexPlayStatusTask.ts b/server/src/tasks/UpdatePlexPlayStatusTask.ts new file mode 100644 index 00000000..c71b8a3a --- /dev/null +++ b/server/src/tasks/UpdatePlexPlayStatusTask.ts @@ -0,0 +1,133 @@ +import { RecurrenceRule } from 'node-schedule'; +import { PlexServerSettings } from '../dao/entities/PlexServerSettings'; +import { PlexApiFactory } from '../external/plex'; +import { run } from '../util'; +import { ScheduledTask } from './ScheduledTask'; +import { Task } from './Task'; +import dayjs from 'dayjs'; +import { GlobalScheduler } from '../services/scheduler'; +import { v4 } from 'uuid'; + +type UpdatePlexPlayStatusScheduleRequest = { + ratingKey: string; + startTime: number; + duration: number; + channelNumber: number; +}; + +type UpdatePlexPlayStatusInvocation = UpdatePlexPlayStatusScheduleRequest & { + playState: PlayState; + sessionId: string; +}; + +type PlayState = 'playing' | 'stopped'; + +const StaticPlexHeaders = { + 'X-Plex-Product': 'Tunarr', + 'X-Plex-Platform': 'Generic', + 'X-Plex-Client-Platform': 'Generic', + 'X-Plex-Client-Profile-Name': 'Generic', +}; + +export class UpdatePlexPlayStatusScheduledTask extends ScheduledTask { + private playState: PlayState = 'playing'; + + constructor( + private plexServer: PlexServerSettings, + private request: UpdatePlexPlayStatusScheduleRequest, + public sessionId: string = v4(), + ) { + super( + UpdatePlexPlayStatusScheduledTask.name, + run(() => { + const rule = new RecurrenceRule(); + rule.second = 30; + return rule; + }), + () => this.getNextTask(), + { visible: false }, + ); + // Kick off leading edge task + GlobalScheduler.scheduleOneOffTask( + UpdatePlexPlayStatusTask.name, + dayjs().add(1, 'second'), + this.getNextTask(), + ); + } + + get id() { + return `${this.name}_${this.sessionId}`; + } + + stop() { + this.scheduledJob.cancel(false); + this.playState = 'stopped'; + GlobalScheduler.scheduleOneOffTask( + UpdatePlexPlayStatusTask.name, + dayjs().add(30, 'seconds').toDate(), + this.getNextTask(), + ); + } + + private getNextTask(): UpdatePlexPlayStatusTask { + const task = new UpdatePlexPlayStatusTask(this.plexServer, { + ...this.request, + playState: this.playState, + sessionId: this.sessionId, + }); + + this.request = { + ...this.request, + startTime: Math.min( + this.request.startTime + 30000, + this.request.duration, + ), + }; + + return task; + } +} + +class UpdatePlexPlayStatusTask extends Task { + public ID: string = UpdatePlexPlayStatusTask.name; + + get taskName(): string { + return this.ID; + } + + constructor( + private plexServer: PlexServerSettings, + private request: UpdatePlexPlayStatusInvocation, + ) { + super(); + } + + protected async runInternal(): Promise { + const plex = PlexApiFactory.get(this.plexServer); + + const deviceName = `tunarr-channel-${this.request.channelNumber}`; + const params = { + ...StaticPlexHeaders, + ratingKey: this.request.ratingKey, + state: this.request.playState, + key: `/library/metadata/${this.request.ratingKey}`, + time: this.request.startTime, + duration: this.request.duration, + 'X-Plex-Device-Name': deviceName, + 'X-Plex-Device': deviceName, + 'X-Plex-Client-Identifier': this.request.sessionId, + }; + + try { + await plex.doPost('/:/timeline', params); + } catch (error) { + this.logger.warn( + error, + `Problem updating Plex status using status URL for item ${this.request.ratingKey}: `, + ); + return false; + } + + return true; + } +} diff --git a/server/src/tasks/updateXmlTvTask.ts b/server/src/tasks/UpdateXmlTvTask.ts similarity index 100% rename from server/src/tasks/updateXmlTvTask.ts rename to server/src/tasks/UpdateXmlTvTask.ts diff --git a/types/src/plex/index.ts b/types/src/plex/index.ts index f0230189..2c387846 100644 --- a/types/src/plex/index.ts +++ b/types/src/plex/index.ts @@ -146,6 +146,73 @@ export type PlexLibraryCollections = z.infer< typeof PlexLibraryCollectionsSchema >; +const BasePlexMediaStreamSchema = z.object({ + default: z.boolean().optional(), + codec: z.string(), + index: z.number(), + bitrate: z.number(), + bitDepth: z.number().optional(), + displayTitle: z.string().optional(), +}); + +export const PlexMediaVideoStreamSchema = BasePlexMediaStreamSchema.extend({ + streamType: z.literal(1), + chromaLocation: z.string().optional(), + chromaSubsampling: z.string().optional(), + codedHeight: z.number().optional(), + codedWidth: z.number().optional(), + colorPrimaries: z.string().optional(), + colorRange: z.string().optional(), + colorSpace: z.string().optional(), + colorTrc: z.string().optional(), + frameRate: z.number(), + hasScalingMatrix: z.boolean().optional(), + height: z.number(), + width: z.number(), + level: z.number().optional(), + profile: z.string().optional(), + scanType: z.string().optional(), + anamorphic: z.string().or(z.boolean()).optional(), + pixelAspectRatio: z.string().optional(), +}); + +export type PlexMediaVideoStream = z.infer; + +export const PlexMediaAudioStreamSchema = BasePlexMediaStreamSchema.extend({ + streamType: z.literal(2), + selected: z.boolean().optional(), + channels: z.number().optional(), + language: z.string().optional(), + languageTag: z.string().optional(), + languageCode: z.string().optional(), + audioChannelLayout: z.string().optional(), + profile: z.string().optional(), + samplingRate: z.number().optional(), +}); + +export type PlexMediaAudioStream = z.infer; + +export const PlexMediaSubtitleStreamSchema = BasePlexMediaStreamSchema.extend({ + streamType: z.literal(3), + language: z.string().optional(), + languageTag: z.string().optional(), + languageCode: z.string().optional(), + headerCompression: z.boolean().optional(), +}).partial({ + bitrate: true, + index: true, +}); + +export type PlexMediaSubtitleStream = z.infer< + typeof PlexMediaSubtitleStreamSchema +>; + +export const PlexMediaStreamSchema = z.discriminatedUnion('streamType', [ + PlexMediaVideoStreamSchema, + PlexMediaAudioStreamSchema, + PlexMediaSubtitleStreamSchema, +]); + export const PlexMediaDescriptionSchema = z.object({ id: z.number(), duration: z.number(), @@ -171,6 +238,7 @@ export const PlexMediaDescriptionSchema = z.object({ audioProfile: z.string().optional(), container: z.string(), videoProfile: z.string().optional(), // video only + Stream: z.array(PlexMediaStreamSchema), }), ), }); @@ -605,6 +673,17 @@ export type PlexCollectionContents = | PlexMovieCollectionContents | PlexTvShowCollectionContents; +export const PlexMediaSchema = z.discriminatedUnion('type', [ + PlexMovieSchema, + PlexTvShowSchema, + PlexTvSeasonSchema, + PlexEpisodeSchema, + PlexLibraryCollectionSchema, + PlexMusicArtistSchema, + PlexMusicAlbumSchema, + PlexMusicTrackSchema, +]); + export type PlexMedia = Alias< | PlexMovie | PlexTvShow @@ -814,3 +893,12 @@ export const PlexTagResultSchema = z.object({ }); export type PlexTagResult = z.infer; + +export const PlexMediaContainerResponseSchema = z.object({ + MediaContainer: z.object({ + size: z.number(), + librarySectionID: z.number(), + librarySectionTitle: z.string(), + Metadata: z.array(PlexMediaSchema), + }), +}); diff --git a/web/src/pages/settings/PlexSettingsPage.tsx b/web/src/pages/settings/PlexSettingsPage.tsx index 219d4ccb..0eb9986a 100644 --- a/web/src/pages/settings/PlexSettingsPage.tsx +++ b/web/src/pages/settings/PlexSettingsPage.tsx @@ -10,7 +10,6 @@ import { VisibilityOff, } from '@mui/icons-material'; import { - Alert, Box, Button, Checkbox, @@ -20,7 +19,6 @@ import { DialogContent, DialogContentText, DialogTitle, - Divider, FormControl, FormControlLabel, FormHelperText, @@ -31,9 +29,7 @@ import { Link, MenuItem, OutlinedInput, - Paper, Select, - SelectChangeEvent, Skeleton, Snackbar, Stack, @@ -61,15 +57,9 @@ import AddPlexServer from '../../components/settings/AddPlexServer.tsx'; import UnsavedNavigationAlert from '../../components/settings/UnsavedNavigationAlert.tsx'; import { CheckboxFormController, - NumericFormControllerText, TypedController, } from '../../components/util/TypedController.tsx'; -import { - handleNumericFormValue, - resolutionFromAnyString, - resolutionToString, - toggle, -} from '../../helpers/util.ts'; +import { toggle } from '../../helpers/util.ts'; import { usePlexServerStatus } from '../../hooks/plexHooks.ts'; import { usePlexServerSettings, @@ -77,40 +67,6 @@ import { } from '../../hooks/settingsHooks.ts'; import { useTunarrApi } from '../../hooks/useTunarrApi.ts'; -const supportedResolutions = [ - '420x420', - '576x320', - '720x480', - '1024x768', - '1280x720', - '1920x1080', - '3840x2160', -]; - -const supportedAudioChannels = [ - '1.0', - '2.0', - '2.1', - '4.0', - '5.0', - '5.1', - '6.1', - '7.1', -]; - -const supportedAudioBoost = [ - { value: 100, string: '0 Seconds' }, - { value: 120, string: '1 Second' }, - { value: 140, string: '2 Seconds' }, - { value: 160, string: '3 Seconds' }, - { value: 180, string: '4 Seconds' }, -]; - -const supportedStreamProtocols = [ - { value: 'http', string: 'HTTP' }, - { value: 'hls', string: 'HLS' }, -]; - const supportedPaths = [ { value: 'plex', string: 'Plex' }, { value: 'direct', string: 'Direct' }, @@ -426,11 +382,7 @@ export default function PlexSettingsPage() { error: serversError, } = usePlexServerSettings(); - const { - data: streamSettings, - isPending: streamSettingsPending, - error: streamsError, - } = usePlexStreamSettings(); + const { data: streamSettings, error: streamsError } = usePlexStreamSettings(); const { reset, @@ -444,7 +396,6 @@ export default function PlexSettingsPage() { }); const streamPath = watch('streamPath'); - const showSubtitles = watch('enableSubtitles'); useEffect(() => { if (streamSettings) { @@ -618,320 +569,6 @@ export default function PlexSettingsPage() { ); }; - const renderStreamSettings = () => { - if (streamSettingsPending) { - return ( - - - - - - - - - - - - - ); - } - - return ( - <> - - - Video Options - - - - ( - - - - - - - - ), - }} - /> - )} - /> - - - - - Max Playable Resolution - - ( - - )} - /> - - - - Max Transcode Resolution - - ( - - )} - /> - - - - - ); - }; - - const renderAudioSettings = () => { - if (streamSettingsPending) { - return ( - - - - - - - - - - - - - ); - } - - return ( - <> - - - Audio Options - - - - ( - - - - - - - - ), - }} - /> - )} - /> - - - - - Maxium Audio Channels - - - (e as SelectChangeEvent).target.value - } - render={({ field }) => ( - - )} - /> - - Note: 7.1 audio and on some clients, 6.1, is known to cause - playback issues. - - - - - - Audio Boost - - (e as SelectChangeEvent).target.value - } - render={({ field }) => ( - - )} - /> - - Note: Only applies when downmixing to stereo. - - - - - - ); - }; - - const renderSubtitleSettings = () => { - if (streamSettingsPending) { - return ( - - - - - - - - - - - - - ); - } - - return ( - <> - - - Subtitle Options - - - - - ( - - )} - /> - } - label="Enable Subtitles (Requires Transcoding)" - /> - - {showSubtitles && ( - - )} - - - - - ); - }; - const renderPathReplacements = () => { return ( <> @@ -947,7 +584,7 @@ export default function PlexSettingsPage() { render={({ field }) => ( )} @@ -961,7 +598,7 @@ export default function PlexSettingsPage() { render={({ field }) => ( )} @@ -973,121 +610,6 @@ export default function PlexSettingsPage() { ); }; - const renderMiscSettings = () => { - if (streamSettingsPending) { - return ( - - - - - - - - - - - - - ); - } - - return ( - <> - - - Miscellaneous Options - - - - - - - - - - - - Stream Protocol - - - (e as SelectChangeEvent).target.value - } - render={({ field }) => ( - - )} - /> - - - ( - - )} - /> - } - label="Force Direct Play" - /> - - - - - ); - }; - return ( {renderServersTable()} - - Plex Transcoding + + Plex Streaming - - If stream changes video codec, audio codec, or audio channels upon - episode change, you will experience playback issues unless ffmpeg - transcoding and normalization are also enabled. - @@ -1140,6 +657,15 @@ export default function PlexSettingsPage() { )} /> + + Plex: This option will initialize the stream + over the network, i.e. stream from the Plex server +
+ Direct: This option attempts to open the file + from the filesystem, using the file path provided by Plex. This + path can be normalized for Tunarr using a find/replace string + combination +
@@ -1171,16 +697,7 @@ export default function PlexSettingsPage() {
- {streamPath === 'plex' ? ( - <> - {renderStreamSettings()} - {renderAudioSettings()} - {renderSubtitleSettings()} - {renderMiscSettings()} - - ) : ( - renderPathReplacements() - )} + {streamPath === 'direct' ? renderPathReplacements() : null}