fix: include subtitle streams in minted programs from PlexApiClient

Also explicitly excludes CC tracks that are embedded in the video stream
This commit is contained in:
Christian Benincasa
2026-04-16 12:17:56 -04:00
parent 70ceb60e47
commit 5724999b28
9 changed files with 307 additions and 197 deletions

View File

@@ -41,6 +41,7 @@ import type {
PlexMediaContainerResponse,
PlexMediaNoCollectionOrPlaylist,
PlexMediaNoCollectionPlaylist,
PlexMediaSubtitleStream,
PlexMediaVideoStream,
PlexTerminalMedia,
} from '@tunarr/types/plex';
@@ -1356,7 +1357,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
directors: plexDirectorInject(plexEpisode.Director),
writers: plexWriterInject(plexEpisode.Writer),
episodeNumber: plexEpisode.index ?? 0,
mediaItem: plexMediaStreamsInject(
mediaItem: this.plexMediaStreamsInject(
plexEpisode.ratingKey,
plexEpisode,
).getOrElse(() => emptyMediaItem(plexEpisode)),
@@ -1528,7 +1529,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
year: plexMovie.year ?? releaseDate?.year() ?? null,
releaseDate: releaseDate?.valueOf() ?? null,
releaseDateString: releaseDate?.format() ?? null,
mediaItem: plexMediaStreamsInject(
mediaItem: this.plexMediaStreamsInject(
plexMovie.ratingKey,
plexMovie,
).getOrElse(() => emptyMediaItem(plexMovie)),
@@ -1610,9 +1611,10 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
year: plexClip.year ?? releaseDate?.year() ?? null,
releaseDate: releaseDate?.valueOf() ?? null,
releaseDateString: releaseDate?.format() ?? null,
mediaItem: plexMediaStreamsInject(plexClip.ratingKey, plexClip).getOrElse(
() => emptyMediaItem(plexClip),
),
mediaItem: this.plexMediaStreamsInject(
plexClip.ratingKey,
plexClip,
).getOrElse(() => emptyMediaItem(plexClip)),
duration: plexClip.duration,
actors: plexActorInject(plexClip.Role),
directors: plexDirectorInject(plexClip.Director),
@@ -1788,7 +1790,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
writers: [],
genres: [],
trackNumber: plexTrack.index ?? 0,
mediaItem: plexMediaStreamsInject(
mediaItem: this.plexMediaStreamsInject(
plexTrack.ratingKey,
plexTrack,
).getOrElse(() => emptyMediaItem(plexTrack)),
@@ -1921,6 +1923,261 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
return;
}
}
plexMediaStreamsInject(
itemId: string,
plexItem: PlexTerminalMedia,
requireVideoStream: boolean = true,
): Result<MediaItem> {
const plexMedia = plexItem.Media;
if (isNil(plexMedia) || isEmpty(plexMedia)) {
return Result.forError(
new Error(`Plex item ID = ${itemId} has no Media streams`),
);
}
const relevantMedia = maxBy(
filter(
plexMedia,
(m) => (m.duration ?? 0) >= 0 && (m.Part?.length ?? 0) > 0,
),
(m) => m.id,
);
if (!relevantMedia) {
return Result.forError(
new Error(
`No Media items on Plex item ID = ${itemId} meet the necessary criteria.`,
),
);
}
const relevantMediaPart = first(relevantMedia?.Part);
const apiMediaStreams = relevantMediaPart?.Stream;
if (!relevantMediaPart || isEmpty(apiMediaStreams)) {
return Result.forError(
new Error(`Could not extract a stream for Plex item ID ${itemId}`),
);
}
const videoStream = find(
apiMediaStreams,
(stream): stream is PlexMediaVideoStream => stream.streamType === 1,
);
if (requireVideoStream && !videoStream) {
return Result.forError(
new Error(`Plex item ID = ${itemId} has no video streams`),
);
}
const streams: MediaStream[] = [];
if (videoStream) {
const videoDetails = {
streamType: 'video',
codec: videoStream.codec,
bitDepth: videoStream.bitDepth ?? 8,
languageCodeISO6392: videoStream.languageCode,
default: videoStream.default,
profile: videoStream.profile?.toLowerCase() ?? '',
index: videoStream.index,
frameRate: videoStream.frameRate,
colorPrimaries: videoStream.colorPrimaries,
colorRange: videoStream.colorRange,
colorSpace: videoStream.colorSpace,
colorTransfer: videoStream.colorTrc,
} satisfies MediaStream;
streams.push(videoDetails);
}
streams.push(
...map(
sortBy(
filter(apiMediaStreams, (stream): stream is PlexMediaAudioStream => {
return stream.streamType === 2 && !isNil(stream.index);
}),
(stream) => [
stream.selected ? -1 : 0,
stream.default ? 0 : 1,
stream.index,
],
),
(audioStream) => {
return {
streamType: 'audio',
// bitrate: audioStream.bitrate,
channels: audioStream.channels,
codec: audioStream.codec,
index: audioStream.index,
// Use the "selected" bit over the "default" if it exists
// In plex, selected signifies that the user's preferences would choose
// this stream over others, even if it is not the default
// This is temporary until we have language preferences within Tunarr
// to pick these streams.
profile: audioStream.profile?.toLocaleLowerCase() ?? '',
selected: audioStream.selected,
default: audioStream.default,
languageCodeISO6392: audioStream.languageCode,
title: audioStream.displayTitle,
} satisfies MediaStream;
},
),
);
streams.push(
...map(
sortBy(
filter(
apiMediaStreams,
(stream): stream is PlexMediaSubtitleStream => {
return stream.streamType === 3 && !stream.embeddedInVideo;
},
),
(stream) => [
stream.selected ? -1 : 0,
// stream.default ? 0 : 1,
stream.index,
],
),
(stream) => {
const sdh = !![
stream.title,
stream.displayTitle,
stream.extendedDisplayTitle,
]
.filter(isNonEmptyString)
.find((s) => s.toLocaleLowerCase().includes('sdh'));
const details = {
streamType: isDefined(stream.index)
? 'subtitles'
: 'external_subtitles',
// type: isDefined(stream.index) ? 'embedded' : 'external',
codec: stream.codec.toLocaleLowerCase(),
default: stream.default ?? false,
index: stream.index ?? 0,
title: stream.displayTitle,
// description: stream.extendedDisplayTitle,
sdh,
// path: stream.key ? this.getFullUrl(stream.key) : undefined,
// languageCodeISO6391: stream.languageTag,
languageCodeISO6392: stream.languageCode,
fileName: isNonEmptyString(stream.key)
? this.getFullUrl(stream.key)
: undefined,
forced: stream.forced ?? false,
} satisfies MediaStream;
// if (details.type === 'external' && isNonEmptyString(stream.key)) {
// const key = stream.key;
// const fullPath =
// await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
// {
// externalKey: plexItem.ratingKey,
// externalSourceId: this.options.mediaSource.uuid,
// sourceType: 'plex',
// uuid:
// },
// details,
// () => this.getSubtitles(key),
// );
// if (fullPath) {
// details.path = fullPath;
// return details;
// }
// this.logger.warn(
// 'Skipping external subtitles at index %d because download failed. Please check logs and file an issue for assistance.',
// stream.index ?? -1,
// );
// return;
// }
return details;
},
),
);
const chapters: MediaChapter[] =
plexItem.type === 'movie' || plexItem.type === 'episode'
? (plexItem.Chapter?.map((chapter) => {
return {
index: chapter.index,
endTime: chapter.endTimeOffset,
startTime: chapter.startTimeOffset,
chapterType: 'chapter',
title: chapter.tag,
} satisfies MediaChapter;
}) ?? [])
: [];
const markers =
plexItem.type === 'movie' || plexItem.type === 'episode'
? (plexItem.Marker ?? [])
: [];
const intros = zipWithIndex(
orderBy(
markers.filter((marker) => marker.type === 'intro'),
(marker) => marker.startTimeOffset,
'asc',
),
).map(
([marker, index]) =>
({
chapterType: 'intro',
endTime: marker.endTimeOffset,
startTime: marker.startTimeOffset,
index,
}) satisfies MediaChapter,
);
const outros = zipWithIndex(
orderBy(
markers.filter((marker) => marker.type === 'credits'),
(marker) => marker.startTimeOffset,
'asc',
),
).map(
([marker, index]) =>
({
chapterType: 'outro',
endTime: marker.endTimeOffset,
startTime: marker.startTimeOffset,
index,
}) satisfies MediaChapter,
);
chapters.push(...intros, ...outros);
return Result.success({
// Handle if this is not present...
duration: relevantMedia.duration!,
sampleAspectRatio: isNonEmptyString(videoStream?.pixelAspectRatio)
? videoStream.pixelAspectRatio
: '1:1',
displayAspectRatio:
(relevantMedia.aspectRatio ?? 0) === 0
? ''
: (relevantMedia.aspectRatio?.toFixed(2) ?? ''),
resolution:
isDefined(relevantMedia.width) && isDefined(relevantMedia.height)
? { widthPx: relevantMedia.width, heightPx: relevantMedia.height }
: undefined,
frameRate: videoStream?.frameRate?.toFixed(2),
streams,
locations: [
{
type: 'remote',
externalKey: relevantMediaPart.key,
path: relevantMediaPart.file,
sourceType: MediaSourceType.Plex,
},
],
chapters,
} satisfies MediaItem);
}
}
type PlexTvDevicesResponse = {
@@ -1986,182 +2243,3 @@ function emptyMediaItem(item: PlexTerminalMedia): Maybe<MediaItem> {
],
};
}
function plexMediaStreamsInject(
itemId: string,
plexItem: PlexTerminalMedia,
requireVideoStream: boolean = true,
): Result<MediaItem> {
const plexMedia = plexItem.Media;
if (isNil(plexMedia) || isEmpty(plexMedia)) {
return Result.forError(
new Error(`Plex item ID = ${itemId} has no Media streams`),
);
}
const relevantMedia = maxBy(
filter(
plexMedia,
(m) => (m.duration ?? 0) >= 0 && (m.Part?.length ?? 0) > 0,
),
(m) => m.id,
);
if (!relevantMedia) {
return Result.forError(
new Error(
`No Media items on Plex item ID = ${itemId} meet the necessary criteria.`,
),
);
}
const relevantMediaPart = first(relevantMedia?.Part);
const apiMediaStreams = relevantMediaPart?.Stream;
if (!relevantMediaPart || isEmpty(apiMediaStreams)) {
return Result.forError(
new Error(`Could not extract a stream for Plex item ID ${itemId}`),
);
}
const videoStream = find(
apiMediaStreams,
(stream): stream is PlexMediaVideoStream => stream.streamType === 1,
);
if (requireVideoStream && !videoStream) {
return Result.forError(
new Error(`Plex item ID = ${itemId} has no video streams`),
);
}
const streams: MediaStream[] = [];
if (videoStream) {
const videoDetails = {
streamType: 'video',
codec: videoStream.codec,
bitDepth: videoStream.bitDepth ?? 8,
languageCodeISO6392: videoStream.languageCode,
default: videoStream.default,
profile: videoStream.profile?.toLowerCase() ?? '',
index: videoStream.index,
frameRate: videoStream.frameRate,
colorPrimaries: videoStream.colorPrimaries,
colorRange: videoStream.colorRange,
colorSpace: videoStream.colorSpace,
colorTransfer: videoStream.colorTrc,
} satisfies MediaStream;
streams.push(videoDetails);
}
streams.push(
...map(
sortBy(
filter(apiMediaStreams, (stream): stream is PlexMediaAudioStream => {
return stream.streamType === 2 && !isNil(stream.index);
}),
(stream) => [
stream.selected ? -1 : 0,
stream.default ? 0 : 1,
stream.index,
],
),
(audioStream) => {
return {
streamType: 'audio',
// bitrate: audioStream.bitrate,
channels: audioStream.channels,
codec: audioStream.codec,
index: audioStream.index,
// Use the "selected" bit over the "default" if it exists
// In plex, selected signifies that the user's preferences would choose
// this stream over others, even if it is not the default
// This is temporary until we have language preferences within Tunarr
// to pick these streams.
profile: audioStream.profile?.toLocaleLowerCase() ?? '',
selected: audioStream.selected,
default: audioStream.default,
languageCodeISO6392: audioStream.languageCode,
title: audioStream.displayTitle,
} satisfies MediaStream;
},
),
);
const chapters: MediaChapter[] =
plexItem.type === 'movie' || plexItem.type === 'episode'
? (plexItem.Chapter?.map((chapter) => {
return {
index: chapter.index,
endTime: chapter.endTimeOffset,
startTime: chapter.startTimeOffset,
chapterType: 'chapter',
title: chapter.tag,
} satisfies MediaChapter;
}) ?? [])
: [];
const markers =
plexItem.type === 'movie' || plexItem.type === 'episode'
? (plexItem.Marker ?? [])
: [];
const intros = zipWithIndex(
orderBy(
markers.filter((marker) => marker.type === 'intro'),
(marker) => marker.startTimeOffset,
'asc',
),
).map(
([marker, index]) =>
({
chapterType: 'intro',
endTime: marker.endTimeOffset,
startTime: marker.startTimeOffset,
index,
}) satisfies MediaChapter,
);
const outros = zipWithIndex(
orderBy(
markers.filter((marker) => marker.type === 'credits'),
(marker) => marker.startTimeOffset,
'asc',
),
).map(
([marker, index]) =>
({
chapterType: 'outro',
endTime: marker.endTimeOffset,
startTime: marker.startTimeOffset,
index,
}) satisfies MediaChapter,
);
chapters.push(...intros, ...outros);
return Result.success({
// Handle if this is not present...
duration: relevantMedia.duration!,
sampleAspectRatio: isNonEmptyString(videoStream?.pixelAspectRatio)
? videoStream.pixelAspectRatio
: '1:1',
displayAspectRatio:
(relevantMedia.aspectRatio ?? 0) === 0
? ''
: (relevantMedia.aspectRatio?.toFixed(2) ?? ''),
resolution:
isDefined(relevantMedia.width) && isDefined(relevantMedia.height)
? { widthPx: relevantMedia.width, heightPx: relevantMedia.height }
: undefined,
frameRate: videoStream?.frameRate?.toFixed(2),
streams,
locations: [
{
type: 'remote',
externalKey: relevantMediaPart.key,
path: relevantMediaPart.file,
sourceType: MediaSourceType.Plex,
},
],
chapters,
} satisfies MediaItem);
}

View File

@@ -45,6 +45,7 @@ export abstract class MediaSourceMovieLibraryScanner<
private searchService: MeilisearchService,
protected programConverter: ProgramConverter,
protected programMinter: ProgramDaoMinter,
// protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(logger, mediaSourceDB);
}

View File

@@ -1,7 +1,7 @@
import { inject, injectable } from 'inversify';
import fs from 'node:fs/promises';
import path from 'path';
import { StreamLineupProgram } from '../db/derived_types/StreamLineup.ts';
import { MediaSourceId, MediaSourceType } from '../db/schema/base.ts';
import { QueryResult } from '../external/BaseApiClient.ts';
import { FileSystemService } from '../services/FileSystemService.ts';
import { KEYS } from '../types/inject.ts';
@@ -17,6 +17,13 @@ type GetSubtitleCallbackArgs = {
extension: string;
};
type ExternalItem = {
externalKey: string;
externalSourceId: MediaSourceId;
sourceType: MediaSourceType;
uuid: string;
};
@injectable()
export class ExternalSubtitleDownloader {
constructor(
@@ -33,7 +40,7 @@ export class ExternalSubtitleDownloader {
* @returns The full path to the downloaded subtitles
*/
async downloadSubtitlesIfNecessary(
item: StreamLineupProgram,
item: ExternalItem,
details: SubtitleStreamDetails,
getSubtitlesCb: (
args: GetSubtitleCallbackArgs,

View File

@@ -344,7 +344,12 @@ export class EmbyStreamDetails extends ExternalStreamDetailsFetcher<EmbyT> {
if (details.type === 'external' && isDefined(index)) {
const fullPath =
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
item,
{
externalKey: item.externalKey,
externalSourceId: item.mediaSourceId,
sourceType: 'emby',
uuid: item.uuid,
},
details,
({ extension: ext }) =>
this.emby.getSubtitles(

View File

@@ -353,7 +353,12 @@ export class JellyfinStreamDetails extends ExternalStreamDetailsFetcher<Jellyfin
if (details.type === 'external' && isDefined(index)) {
const fullPath =
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
item,
{
externalKey: item.externalKey,
externalSourceId: item.mediaSourceId,
sourceType: 'jellyfin',
uuid: item.uuid,
},
details,
({ extension: ext }) =>
this.jellyfin.getSubtitles(

View File

@@ -428,7 +428,7 @@ export class PlexStreamDetails extends ExternalStreamDetailsFetcher<PlexT> {
const subtitleStreamDetails = await seq.asyncCollect(
sortBy(
filter(mediaStreams, (stream): stream is PlexMediaSubtitleStream => {
return stream.streamType === 3;
return stream.streamType === 3 && !stream.embeddedInVideo;
}),
(stream) => [
stream.selected ? -1 : 0,
@@ -462,7 +462,12 @@ export class PlexStreamDetails extends ExternalStreamDetailsFetcher<PlexT> {
const key = stream.key;
const fullPath =
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
item,
{
externalKey: item.externalKey,
externalSourceId: item.mediaSourceId,
sourceType: 'plex',
uuid: item.uuid,
},
details,
() => plexApiClient.getSubtitles(key),
);

View File

@@ -1,5 +1,5 @@
import { seq } from '@tunarr/shared/util';
import { ContentGuideProgram } from '@tunarr/types';
import { ContentGuideProgram, tag } from '@tunarr/types';
import dayjs from 'dayjs';
import { inject, injectable } from 'inversify';
import { isUndefined } from 'lodash-es';
@@ -47,7 +47,7 @@ const DurationExtractionFilter = z.object({
durationMs: z.number(),
});
export type DurationExtractionFilter = z.infer<typeof DurationExtractionFilter>;
type DurationExtractionFilter = z.infer<typeof DurationExtractionFilter>;
const ExtractionFilter = z.discriminatedUnion('type', [
ChannelExtractionFilter,
@@ -59,7 +59,7 @@ const SubtitleExtractorTaskRequest = z.object({
filter: ExtractionFilter.optional(),
});
export type SubtitleExtractorTaskRequest = z.infer<
type SubtitleExtractorTaskRequest = z.infer<
typeof SubtitleExtractorTaskRequest
>;
@@ -230,7 +230,15 @@ export class SubtitleExtractorTask extends Task2<
return;
}
const filePath = getSubtitleCacheFilePath(program, subtitle);
const filePath = getSubtitleCacheFilePath(
{
externalKey: program.externalKey,
externalSourceId: tag(program.externalSourceId),
externalSourceType: program.externalSourceType,
id: program.id,
},
subtitle,
);
if (!filePath) {
return;
}

View File

@@ -1,4 +1,4 @@
import type { MediaSourceType } from '@/db/schema/base.js';
import type { MediaSourceId, MediaSourceType } from '@/db/schema/base.js';
import crypto from 'node:crypto';
import path from 'path';
import { match, P } from 'ts-pattern';
@@ -8,7 +8,7 @@ import type { Nullable } from '../types/util.ts';
type MinimalProgram = {
id: string;
externalSourceType: MediaSourceType;
externalSourceId: string;
externalSourceId: MediaSourceId;
externalKey: string;
};

View File

@@ -318,6 +318,7 @@ export const PlexMediaSubtitleStreamSchema = BasePlexMediaStreamSchema.extend({
.boolean()
.or(z.number().transform((i) => i === 1))
.optional(),
embeddedInVideo: z.coerce.number().optional(),
}).partial({
bitrate: true,
index: true,