mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
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:
448
server/src/external/plex/PlexApiClient.ts
vendored
448
server/src/external/plex/PlexApiClient.ts
vendored
@@ -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);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export abstract class MediaSourceMovieLibraryScanner<
|
||||
private searchService: MeilisearchService,
|
||||
protected programConverter: ProgramConverter,
|
||||
protected programMinter: ProgramDaoMinter,
|
||||
// protected externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(logger, mediaSourceDB);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user