mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
Initial implementation of local media libraries. Includes local scanners for movie and TV library types. Saves extracted metadata locally. Some things are missing, including: * Saving all metadata locally, including genres, actors, etc. * blurhash extraction - this is computationally expensive at scale and should be done async * Hooking up subtitle extraction to new subtitle DB tables
2042 lines
59 KiB
TypeScript
2042 lines
59 KiB
TypeScript
import { MediaSourceType } from '@/db/schema/base.js';
|
|
import type { Nilable, Nullable } from '@/types/util.js';
|
|
import { type Maybe } from '@/types/util.js';
|
|
import { getChannelId } from '@/util/channels.js';
|
|
import {
|
|
caughtErrorToError,
|
|
inConstArr,
|
|
isDefined,
|
|
isNonEmptyString,
|
|
zipWithIndex,
|
|
} from '@/util/index.js';
|
|
import { getTunarrVersion } from '@/util/version.js';
|
|
import { PlexClientIdentifier } from '@tunarr/shared/constants';
|
|
import { seq } from '@tunarr/shared/util';
|
|
import type {
|
|
Collection,
|
|
Library,
|
|
MediaChapter,
|
|
Playlist,
|
|
ProgramOrFolder,
|
|
} from '@tunarr/types';
|
|
import type { MediaSourceStatus, PagedResult } from '@tunarr/types/api';
|
|
import type {
|
|
PlexEpisode as ApiPlexEpisode,
|
|
PlexMovie as ApiPlexMovie,
|
|
PlexMusicAlbum as ApiPlexMusicAlbum,
|
|
PlexMusicArtist as ApiPlexMusicArtist,
|
|
PlexMusicTrack as ApiPlexMusicTrack,
|
|
PlexTvSeason as ApiPlexTvSeason,
|
|
PlexTvShow as ApiPlexTvShow,
|
|
PlexJoinItem,
|
|
PlexMediaAudioStream,
|
|
PlexMediaContainerMetadata,
|
|
PlexMediaContainerResponse,
|
|
PlexMediaNoCollectionOrPlaylist,
|
|
PlexMediaNoCollectionPlaylist,
|
|
PlexMediaVideoStream,
|
|
PlexTerminalMedia,
|
|
} from '@tunarr/types/plex';
|
|
import {
|
|
MakePlexMediaContainerResponseSchema,
|
|
PlexContainerStatsSchema,
|
|
type PlexDvr,
|
|
type PlexDvrsResponse,
|
|
PlexEpisodeSchema,
|
|
PlexFilterMediaContainerResponseSchema,
|
|
PlexGenericMediaContainerResponseSchema,
|
|
PlexLibrariesResponseSchema,
|
|
PlexLibraryCollectionSchema,
|
|
type PlexMedia,
|
|
PlexMediaContainerResponseSchema,
|
|
PlexMediaNoCollectionPlaylistResponse,
|
|
type PlexMetadataResponse,
|
|
PlexMovieMediaContainerResponseSchema,
|
|
PlexMusicAlbumSchema,
|
|
PlexMusicArtistSchema,
|
|
PlexMusicTrackSchema,
|
|
PlexPlaylistSchema,
|
|
type PlexResource,
|
|
PlexTagResultSchema,
|
|
PlexTvSeasonSchema,
|
|
PlexTvShowSchema,
|
|
PlexUserSchema,
|
|
} from '@tunarr/types/plex';
|
|
import {
|
|
type AxiosRequestConfig,
|
|
isAxiosError,
|
|
type RawAxiosRequestHeaders,
|
|
} from 'axios';
|
|
import dayjs from 'dayjs';
|
|
import { XMLParser } from 'fast-xml-parser';
|
|
import {
|
|
compact,
|
|
filter,
|
|
find,
|
|
first,
|
|
flatMap,
|
|
forEach,
|
|
isEmpty,
|
|
isError,
|
|
isNil,
|
|
isUndefined,
|
|
map,
|
|
maxBy,
|
|
orderBy,
|
|
reject,
|
|
sortBy,
|
|
} from 'lodash-es';
|
|
import { match, P } from 'ts-pattern';
|
|
import { v4 } from 'uuid';
|
|
import type { z } from 'zod/v4';
|
|
import type { PageParams } from '../../db/interfaces/IChannelDB.ts';
|
|
import type { MediaSourceLibraryOrm } from '../../db/schema/MediaSource.ts';
|
|
import { ProgramType, ProgramTypes } from '../../db/schema/Program.js';
|
|
import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.js';
|
|
import type { Canonicalizer } from '../../services/Canonicalizer.ts';
|
|
import type { WrappedError } from '../../types/errors.ts';
|
|
import type {
|
|
MediaItem,
|
|
MediaStream,
|
|
NamedEntity,
|
|
PlexAlbum,
|
|
PlexArtist,
|
|
PlexEpisode,
|
|
PlexItem,
|
|
PlexMovie,
|
|
PlexOtherVideo,
|
|
PlexSeason,
|
|
PlexShow,
|
|
PlexTrack,
|
|
} from '../../types/Media.js';
|
|
import { Result } from '../../types/result.ts';
|
|
import { parsePlexGuid } from '../../util/externalIds.ts';
|
|
import iterators from '../../util/iterator.ts';
|
|
import { titleToSortTitle } from '../../util/programs.ts';
|
|
import type { ApiClientOptions } from '../BaseApiClient.js';
|
|
import { QueryError, type QueryResult } from '../BaseApiClient.js';
|
|
import { MediaSourceApiClient } from '../MediaSourceApiClient.ts';
|
|
import { PlexQueryCache } from './PlexQueryCache.js';
|
|
import { PlexRequestRedacter } from './PlexRequestRedacter.ts';
|
|
|
|
const PlexCache = new PlexQueryCache();
|
|
|
|
const PlexHeaders = {
|
|
'X-Plex-Product': 'Tunarr',
|
|
'X-Plex-Client-Identifier': PlexClientIdentifier,
|
|
};
|
|
|
|
type PlexTypes = {
|
|
[ProgramType.Movie]: PlexMovie;
|
|
[ProgramGroupingType.Show]: PlexShow;
|
|
[ProgramGroupingType.Season]: PlexSeason;
|
|
[ProgramType.Episode]: PlexEpisode;
|
|
[ProgramGroupingType.Artist]: PlexArtist;
|
|
[ProgramGroupingType.Album]: PlexAlbum;
|
|
[ProgramType.Track]: PlexTrack;
|
|
};
|
|
|
|
export type PlexApiClientFactory = (opts: ApiClientOptions) => PlexApiClient;
|
|
|
|
export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
|
protected redacter = new PlexRequestRedacter();
|
|
|
|
constructor(
|
|
private canonicalizer: Canonicalizer<PlexMedia>,
|
|
opts: ApiClientOptions,
|
|
) {
|
|
super({
|
|
...opts,
|
|
extraHeaders: {
|
|
...PlexHeaders,
|
|
'X-Plex-Version': getTunarrVersion(),
|
|
'X-Plex-Token': opts.mediaSource.accessToken,
|
|
},
|
|
queueOpts: {
|
|
concurrency: 5,
|
|
interval: dayjs.duration({ seconds: 1 }),
|
|
},
|
|
});
|
|
}
|
|
|
|
get serverName() {
|
|
return this.options.mediaSource.name;
|
|
}
|
|
|
|
get serverId() {
|
|
return this.options.mediaSource.uuid;
|
|
}
|
|
|
|
getFullUrl(path: string): string {
|
|
const url = super.getFullUrl(path);
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set(
|
|
'X-Plex-Token',
|
|
this.options.mediaSource.accessToken,
|
|
);
|
|
return parsed.toString();
|
|
}
|
|
|
|
// TODO: make all callers use this
|
|
private async doGetResult<T extends PlexMediaContainerMetadata>(
|
|
path: string,
|
|
config: Partial<Omit<AxiosRequestConfig, 'method' | 'url'>> = {},
|
|
skipCache: boolean = false,
|
|
): Promise<QueryResult<T>> {
|
|
const getter = async (): Promise<QueryResult<T>> => {
|
|
const req: AxiosRequestConfig = {
|
|
method: 'get',
|
|
url: path,
|
|
headers: config.headers,
|
|
};
|
|
|
|
if (this.options.mediaSource.accessToken === '') {
|
|
throw new Error(
|
|
'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.',
|
|
);
|
|
}
|
|
|
|
try {
|
|
const res = await this.doRequest<PlexMediaContainerResponse<T>>(req);
|
|
if (isUndefined(res?.MediaContainer)) {
|
|
this.logger.error(res, 'Expected MediaContainer, got %O', res);
|
|
return this.makeErrorResult('parse_error');
|
|
}
|
|
|
|
return this.makeSuccessResult(res?.MediaContainer);
|
|
} catch (err) {
|
|
if (isAxiosError(err) && err.response?.status === 404) {
|
|
return this.makeErrorResult('not_found');
|
|
}
|
|
|
|
const error = caughtErrorToError(err);
|
|
|
|
return this.makeErrorResult('generic_request_error', error.message);
|
|
}
|
|
};
|
|
|
|
return this.options.enableRequestCache && !skipCache
|
|
? await PlexCache.getOrSetPlexResult<T>(
|
|
this.options.mediaSource.name,
|
|
path,
|
|
getter,
|
|
)
|
|
: await getter();
|
|
}
|
|
|
|
// We're just keeping the old contract here right now...
|
|
async doGetPath<T extends PlexMediaContainerMetadata>(
|
|
path: string,
|
|
optionalHeaders: RawAxiosRequestHeaders = {},
|
|
skipCache: boolean = false,
|
|
): Promise<Maybe<T>> {
|
|
const result = await this.doGetResult<T>(
|
|
path,
|
|
{ headers: optionalHeaders },
|
|
skipCache,
|
|
);
|
|
|
|
return result.orUndefined();
|
|
}
|
|
|
|
async getFilters(key: string) {
|
|
return await this.doTypeCheckedGet(
|
|
`/library/sections/${key}/all`,
|
|
PlexFilterMediaContainerResponseSchema,
|
|
{
|
|
params: {
|
|
includeMeta: '1',
|
|
includeAdvanced: '1',
|
|
'X-Plex-Container-Start': 0,
|
|
'X-Plex-Container-Size': 0,
|
|
},
|
|
},
|
|
).then((res) => res.map((d) => d.MediaContainer));
|
|
}
|
|
|
|
async getTags(libraryKey: string, itemKey: string) {
|
|
return await this.doTypeCheckedGet(
|
|
`/library/sections/${libraryKey}/${itemKey}`,
|
|
PlexTagResultSchema,
|
|
);
|
|
}
|
|
|
|
getMovieLibraryContents(
|
|
libraryId: string,
|
|
pageSize: number = 50,
|
|
): AsyncIterable<PlexMovie> {
|
|
return this.iterateChildItems(
|
|
libraryId,
|
|
PlexMovieMediaContainerResponseSchema,
|
|
(movie, library) => this.plexMovieInjection(movie, library),
|
|
pageSize,
|
|
);
|
|
}
|
|
|
|
getTvShowLibraryContents(
|
|
libraryId: string,
|
|
pageSize: number = 50,
|
|
): AsyncGenerator<PlexShow> {
|
|
return this.iterateChildItems(
|
|
libraryId,
|
|
MakePlexMediaContainerResponseSchema(PlexTvShowSchema),
|
|
(show, library) => this.plexShowInjection(show, library),
|
|
pageSize,
|
|
);
|
|
}
|
|
|
|
getShowSeasons(tvShowKey: string, pageSize: number = 50) {
|
|
return this.iterateChildItems(
|
|
tvShowKey,
|
|
MakePlexMediaContainerResponseSchema(PlexTvSeasonSchema),
|
|
(season, library) => this.plexSeasonInjection(season, library),
|
|
pageSize,
|
|
`/library/metadata/${tvShowKey}/children`,
|
|
);
|
|
}
|
|
|
|
getSeasonEpisodes(
|
|
tvSeasonKey: string,
|
|
pageSize: number = 50,
|
|
): AsyncIterable<PlexEpisode> {
|
|
return this.iterateChildItems(
|
|
tvSeasonKey,
|
|
MakePlexMediaContainerResponseSchema(PlexEpisodeSchema),
|
|
(ep, library) => this.plexEpisodeInjection(ep, library),
|
|
pageSize,
|
|
`/library/metadata/${tvSeasonKey}/children`,
|
|
);
|
|
}
|
|
|
|
getMusicLibraryContents(
|
|
libraryId: string,
|
|
pageSize: number = 50,
|
|
): AsyncIterable<PlexArtist> {
|
|
return this.iterateChildItems(
|
|
libraryId,
|
|
MakePlexMediaContainerResponseSchema(PlexMusicArtistSchema),
|
|
(artist, library) => this.plexMusicArtistInjection(artist, library),
|
|
pageSize,
|
|
);
|
|
}
|
|
|
|
getArtistAlbums(
|
|
artistKey: string,
|
|
pageSize: number = 50,
|
|
): AsyncIterable<PlexAlbum> {
|
|
return this.iterateChildItems(
|
|
artistKey,
|
|
MakePlexMediaContainerResponseSchema(PlexMusicAlbumSchema),
|
|
(album, library) => this.plexAlbumInjection(album, library),
|
|
pageSize,
|
|
`/library/metadata/${artistKey}/children`,
|
|
);
|
|
}
|
|
|
|
getAlbumTracks(
|
|
albumKey: string,
|
|
pageSize: number = 50,
|
|
): AsyncIterable<PlexTrack> {
|
|
return this.iterateChildItems(
|
|
albumKey,
|
|
MakePlexMediaContainerResponseSchema(PlexMusicTrackSchema),
|
|
(track, library) => this.plexTrackInjection(track, library),
|
|
pageSize,
|
|
`/library/metadata/${albumKey}/children`,
|
|
);
|
|
}
|
|
|
|
async getMusicTrack(key: string): Promise<QueryResult<PlexTrack>> {
|
|
const queryResult = await this.getItemMetadataInternal(
|
|
key,
|
|
MakePlexMediaContainerResponseSchema(PlexMusicTrackSchema),
|
|
);
|
|
|
|
return queryResult.flatMap((track) =>
|
|
this.findLibraryFromPlexMedia(track)
|
|
.flatMap((library) => this.plexTrackInjection(track, library))
|
|
.mapError((e) => QueryError.genericQueryError(e.message)),
|
|
);
|
|
}
|
|
|
|
private async *iterateChildItems<
|
|
OutType,
|
|
ItemType extends PlexMediaNoCollectionOrPlaylist,
|
|
>(
|
|
libraryId: string,
|
|
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
|
|
converter: (
|
|
item: ItemType,
|
|
libraryId: MediaSourceLibraryOrm,
|
|
) => Result<OutType>,
|
|
pageSize: number = 50,
|
|
key: string = `/library/sections/${libraryId}/all`,
|
|
): AsyncGenerator<OutType> {
|
|
const count = await this.getChildCount(key);
|
|
if (count.isFailure()) {
|
|
throw count.error;
|
|
}
|
|
|
|
const totalPages = Math.ceil(count.get() / pageSize);
|
|
for (let page = 0; page <= totalPages; page++) {
|
|
const chunkResult = await this.doTypeCheckedGet(key, schema, {
|
|
params: {
|
|
'X-Plex-Container-Size': pageSize,
|
|
'X-Plex-Container-Start': page * pageSize,
|
|
},
|
|
});
|
|
|
|
if (chunkResult.isFailure()) {
|
|
throw chunkResult.error;
|
|
}
|
|
|
|
const mediaContainer = chunkResult.get().MediaContainer;
|
|
const responseLibraryId = mediaContainer.librarySectionID?.toString();
|
|
for (const item of mediaContainer.Metadata ?? []) {
|
|
const externalLibraryId =
|
|
item.librarySectionID?.toString() ?? responseLibraryId ?? libraryId;
|
|
const library = this.findLibraryFromPlexMedia(item, externalLibraryId);
|
|
|
|
if (library.isFailure()) {
|
|
this.logger.warn(
|
|
'Could not find matching library for Plex library ID %s. Try resyncing Plex libraries.',
|
|
externalLibraryId,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const converted = converter(item, library.get());
|
|
|
|
if (converted.isFailure()) {
|
|
this.logger.warn(
|
|
converted.error,
|
|
'Failed to convert Plex API item %s',
|
|
item.ratingKey,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
yield converted.get();
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
async getLibrariesRaw() {
|
|
return this.doTypeCheckedGet(
|
|
'/library/sections',
|
|
PlexLibrariesResponseSchema,
|
|
);
|
|
}
|
|
|
|
async getLibraries() {
|
|
const result = await this.getLibrariesRaw();
|
|
return result.mapPure((data) =>
|
|
data.MediaContainer.Directory.map(
|
|
(lib) =>
|
|
({
|
|
type: 'library',
|
|
externalId: lib.key,
|
|
locations:
|
|
lib.Location?.map((loc) => ({
|
|
type: 'local',
|
|
path: loc.path,
|
|
})) ?? [],
|
|
sourceType: 'plex',
|
|
title: lib.title,
|
|
uuid: v4(), // May get replaced later if we have a match
|
|
}) satisfies Library,
|
|
),
|
|
);
|
|
}
|
|
|
|
async getLibraryCollections(
|
|
libraryId: string,
|
|
paging?: PageParams,
|
|
): Promise<QueryResult<PagedResult<Collection[]>>> {
|
|
const pageParams = paging
|
|
? {
|
|
'X-Plex-Container-Start': paging.offset,
|
|
'X-Plex-Container-Size': paging.limit,
|
|
}
|
|
: {};
|
|
const result = await this.doTypeCheckedGet(
|
|
`/library/sections/${libraryId}/collections`,
|
|
MakePlexMediaContainerResponseSchema(PlexLibraryCollectionSchema),
|
|
{
|
|
params: {
|
|
...pageParams,
|
|
},
|
|
},
|
|
);
|
|
|
|
return result.flatMapPure<PagedResult<Collection[]>>((data) => {
|
|
const library = this.options.mediaSource.libraries.find(
|
|
(lib) => lib.externalKey === libraryId,
|
|
);
|
|
if (!library) {
|
|
return this.makeErrorResult(
|
|
'generic_request_error',
|
|
`Could not find matching library in DB for key = ${libraryId}`,
|
|
);
|
|
}
|
|
|
|
const collections = (data.MediaContainer.Metadata ?? []).map(
|
|
(collection) =>
|
|
({
|
|
type: 'collection',
|
|
externalId: collection.ratingKey,
|
|
libraryId: library.uuid,
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
title: collection.title,
|
|
uuid: v4(),
|
|
sourceType: MediaSourceType.Plex,
|
|
childCount: collection.childCount,
|
|
childType: inConstArr(ProgramTypes, collection.subtype)
|
|
? (collection.subtype as Collection['childType'])
|
|
: undefined,
|
|
}) satisfies Collection,
|
|
);
|
|
|
|
return this.makeSuccessResult({
|
|
size: collections.length,
|
|
result: collections,
|
|
total:
|
|
data.MediaContainer.totalSize ??
|
|
(isNil(paging?.offset) ? collections.length : 0),
|
|
offset: paging?.offset,
|
|
});
|
|
});
|
|
}
|
|
|
|
async getLibraryCount(libraryId: string) {
|
|
return this.getChildCount(`/library/sections/${libraryId}/all`);
|
|
}
|
|
|
|
async getItemChildCount(key: string) {
|
|
return this.getChildCount(`/library/metadata/${key}/children`);
|
|
}
|
|
|
|
async getPlaylists(
|
|
libraryId?: string,
|
|
paging?: PageParams,
|
|
): Promise<QueryResult<PagedResult<Playlist[]>>> {
|
|
const params = {};
|
|
|
|
if (paging) {
|
|
params['X-Plex-Container-Start'] = paging.offset;
|
|
params['X-Plex-Container-Size'] = paging.limit;
|
|
}
|
|
|
|
if (libraryId) {
|
|
params['sectionID'] = libraryId;
|
|
params['type'] = '15';
|
|
}
|
|
|
|
const result = await this.doTypeCheckedGet(
|
|
'/playlists',
|
|
MakePlexMediaContainerResponseSchema(PlexPlaylistSchema),
|
|
{
|
|
params,
|
|
},
|
|
);
|
|
|
|
return result.mapPure((data) => {
|
|
const playlists = (data.MediaContainer.Metadata ?? []).map(
|
|
(playlist) =>
|
|
({
|
|
externalId: playlist.ratingKey,
|
|
libraryId: '',
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
sourceType: this.options.mediaSource.type,
|
|
title: playlist.title,
|
|
type: 'playlist',
|
|
uuid: v4(),
|
|
childCount: playlist.leafCount,
|
|
}) satisfies Playlist,
|
|
);
|
|
|
|
return {
|
|
result: playlists,
|
|
size: playlists.length,
|
|
total: data.MediaContainer.totalSize ?? playlists.length,
|
|
offset: paging?.offset,
|
|
};
|
|
});
|
|
}
|
|
|
|
private getChildCount(key: string) {
|
|
return this.doTypeCheckedGet(key, PlexContainerStatsSchema, {
|
|
params: {
|
|
'X-Plex-Container-Size': 0,
|
|
'X-Plex-Container-Start': 0,
|
|
},
|
|
}).then((result) =>
|
|
result.map(
|
|
(stats) => stats.MediaContainer.totalSize ?? stats?.MediaContainer.size,
|
|
),
|
|
);
|
|
}
|
|
|
|
private async getItemMetadataInternal<ItemType>(
|
|
key: string,
|
|
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
|
|
): Promise<QueryResult<ItemType>> {
|
|
const responseResult = await this.doTypeCheckedGet(
|
|
`/library/metadata/${key}`,
|
|
schema,
|
|
{
|
|
params: {
|
|
includeMarkers: 1,
|
|
includeChapters: 1,
|
|
includeChildren: 1,
|
|
includeLoudnessRamps: 1,
|
|
includeExtras: 1,
|
|
},
|
|
},
|
|
);
|
|
|
|
return responseResult
|
|
.flatMap<ItemType>((parsedResponse) => {
|
|
const media = first(parsedResponse.MediaContainer.Metadata);
|
|
if (!isUndefined(media)) {
|
|
return this.makeSuccessResult<ItemType>(media);
|
|
}
|
|
this.logger.error(
|
|
'Could not extract Metadata object for Plex media, key = %s',
|
|
key,
|
|
);
|
|
return this.makeErrorResult('parse_error');
|
|
})
|
|
.mapError((e) =>
|
|
QueryError.isQueryError(e)
|
|
? e
|
|
: QueryError.genericQueryError(e.message),
|
|
);
|
|
}
|
|
|
|
async getItemMetadata(key: string): Promise<QueryResult<PlexMedia>> {
|
|
return this.getItemMetadataInternal(key, PlexMediaContainerResponseSchema);
|
|
}
|
|
|
|
async getMovie(key: string): Promise<QueryResult<PlexMovie>> {
|
|
return this.getMediaOfType(
|
|
key,
|
|
PlexMovieMediaContainerResponseSchema,
|
|
(movie, library) => this.plexMovieInjection(movie, library),
|
|
);
|
|
}
|
|
|
|
async getVideo(key: string): Promise<QueryResult<PlexOtherVideo>> {
|
|
return this.getMediaOfType(
|
|
key,
|
|
PlexMediaNoCollectionPlaylistResponse,
|
|
(video, library) => {
|
|
if (video.type !== 'movie' || video.subtype !== 'clip') {
|
|
return this.makeErrorResult(
|
|
'generic_request_error',
|
|
`Got unexpected Plex item of type ${video.type} (subtype = ${video.type === 'movie' ? video.subtype : 'none'})`,
|
|
);
|
|
}
|
|
return this.plexOtherVideoInjection(video, library);
|
|
},
|
|
);
|
|
}
|
|
|
|
async getShow(externalKey: string): Promise<QueryResult<PlexShow>> {
|
|
return this.getMediaOfType(
|
|
externalKey,
|
|
MakePlexMediaContainerResponseSchema(PlexTvShowSchema),
|
|
(show, library) => this.plexShowInjection(show, library),
|
|
);
|
|
}
|
|
|
|
private async getMediaOfType<
|
|
ItemType extends PlexMediaNoCollectionOrPlaylist,
|
|
OutType,
|
|
>(
|
|
externalKey: string,
|
|
schema: z.ZodType<PlexMetadataResponse<ItemType>>,
|
|
converter: (
|
|
plexItem: ItemType,
|
|
library: MediaSourceLibraryOrm,
|
|
) => Result<OutType>,
|
|
): Promise<QueryResult<OutType>> {
|
|
const queryResult = await this.getItemMetadataInternal(externalKey, schema);
|
|
return queryResult.flatMap((show) => {
|
|
return this.findLibraryFromPlexMedia(show).flatMap((library) =>
|
|
converter(show, library).ifNil(
|
|
QueryError.create(
|
|
'generic_request_error',
|
|
`Could not convert Plex show ID = ${externalKey}`,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
private findLibraryFromPlexMedia(
|
|
media: PlexMediaNoCollectionOrPlaylist,
|
|
libraryId?: string,
|
|
): QueryResult<MediaSourceLibraryOrm> {
|
|
libraryId ??= media.librarySectionID?.toString();
|
|
if (!isNonEmptyString(libraryId)) {
|
|
return this.makeErrorResult(
|
|
'generic_request_error',
|
|
`Missing librarySectionID for Plex show ${media.ratingKey}`,
|
|
);
|
|
}
|
|
|
|
const library = this.findMatchingLibrary(libraryId);
|
|
|
|
if (!library) {
|
|
return this.makeErrorResult(
|
|
'generic_request_error',
|
|
`Could not find matching library for Plex library ID ${libraryId}. Try syncing your libraries!`,
|
|
);
|
|
}
|
|
|
|
return this.makeSuccessResult(library);
|
|
}
|
|
|
|
async getSeason(key: string): Promise<QueryResult<PlexSeason>> {
|
|
return this.getMediaOfType(
|
|
key,
|
|
MakePlexMediaContainerResponseSchema(PlexTvSeasonSchema),
|
|
(season, library) => this.plexSeasonInjection(season, library),
|
|
);
|
|
}
|
|
|
|
async getEpisode(key: string): Promise<QueryResult<PlexEpisode>> {
|
|
return this.getMediaOfType(
|
|
key,
|
|
MakePlexMediaContainerResponseSchema(PlexEpisodeSchema),
|
|
(episode, library) => this.plexEpisodeInjection(episode, library),
|
|
);
|
|
}
|
|
|
|
async search(
|
|
key: string,
|
|
pageParam: Maybe<{ offset: number; limit: number }>,
|
|
searchParam: Maybe<string>,
|
|
parent: Maybe<string>,
|
|
): Promise<Result<PagedResult<PlexItem[]>>> {
|
|
const mediaSourceId = this.options.mediaSource.uuid;
|
|
if (!mediaSourceId) {
|
|
return Result.forError(
|
|
new Error('Cannot request this resource without a mediaSourceId'),
|
|
);
|
|
}
|
|
|
|
const plexQuery = new URLSearchParams();
|
|
|
|
if (!isUndefined(pageParam)) {
|
|
plexQuery.set('X-Plex-Container-Start', pageParam.offset.toString());
|
|
plexQuery.set('X-Plex-Container-Size', pageParam.limit.toString());
|
|
}
|
|
|
|
// We cannot search when scoped to a parent
|
|
if (isEmpty(parent)) {
|
|
// HACK for now
|
|
forEach(searchParam?.split('&'), (keyval) => {
|
|
const idx = keyval.lastIndexOf('=');
|
|
if (idx !== -1) {
|
|
plexQuery.append(keyval.substring(0, idx), keyval.substring(idx + 1));
|
|
}
|
|
});
|
|
}
|
|
|
|
const path = match(parent)
|
|
.with('collection', () => `/library/collections/${key}/children`)
|
|
.with('playlist', () => `/playlists/${key}/items`)
|
|
.with(P.nonNullable, () => {
|
|
plexQuery.append('excludeAllLeaves', '1');
|
|
return `/library/metadata/${key}/children`;
|
|
})
|
|
.otherwise(() => `/library/sections/${key}/all`);
|
|
|
|
const result = await this.doTypeCheckedGet(
|
|
path,
|
|
PlexMediaNoCollectionPlaylistResponse,
|
|
{
|
|
params: plexQuery.entries().reduce(
|
|
(acc, [key, val]) => {
|
|
acc[key] = val;
|
|
return acc;
|
|
},
|
|
{} as Record<string, string>,
|
|
),
|
|
},
|
|
);
|
|
|
|
return result.map((data) => {
|
|
const items = seq.collect(data.MediaContainer.Metadata, (m) =>
|
|
this.convertPlexResponse(
|
|
m,
|
|
m.librarySectionID?.toString() ??
|
|
data.MediaContainer.librarySectionID?.toString(),
|
|
),
|
|
);
|
|
|
|
return {
|
|
total: data.MediaContainer.totalSize ?? -1,
|
|
result: items,
|
|
size: items.length,
|
|
};
|
|
});
|
|
}
|
|
|
|
async getItemChildren(
|
|
key: string,
|
|
itemType: 'item' | 'collection' | 'playlist',
|
|
): Promise<Result<ProgramOrFolder[]>> {
|
|
const mediaSourceId = this.options.mediaSource.uuid;
|
|
if (!mediaSourceId) {
|
|
return Result.forError(
|
|
new Error('Cannot request this resource without a mediaSourceId'),
|
|
);
|
|
}
|
|
|
|
const path = match(itemType)
|
|
.with('collection', () => `/library/collections/${key}/children`)
|
|
.with('playlist', () => `/playlists/${key}/items`)
|
|
.with('item', () => `/library/metadata/${key}/children`)
|
|
.exhaustive();
|
|
|
|
const response = await this.doTypeCheckedGet(
|
|
path,
|
|
PlexMediaNoCollectionPlaylistResponse,
|
|
{
|
|
params: {
|
|
includeChapters: 1,
|
|
includeMarkers: 1,
|
|
includeElements: [
|
|
'Media',
|
|
'Part',
|
|
'Stream',
|
|
'Genre',
|
|
'Rating',
|
|
'Collection',
|
|
'Director',
|
|
'Writer',
|
|
'Role',
|
|
'Producer',
|
|
].join(','),
|
|
},
|
|
},
|
|
);
|
|
|
|
return response.map((data) => {
|
|
return seq.collect(data.MediaContainer.Metadata, (m) =>
|
|
this.convertPlexResponse(
|
|
m,
|
|
m.librarySectionID?.toString() ??
|
|
data.MediaContainer.librarySectionID?.toString(),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
getOtherVideosLibraryContents(
|
|
parentId: string,
|
|
): AsyncIterable<PlexOtherVideo> {
|
|
const generator = this.iterateChildItems(
|
|
parentId,
|
|
PlexMediaNoCollectionPlaylistResponse,
|
|
(item, lib) => {
|
|
if (item.type !== 'movie' || item.subtype !== 'clip') {
|
|
return Result.success(null);
|
|
}
|
|
return this.plexOtherVideoInjection(item, lib);
|
|
},
|
|
);
|
|
return iterators.compact(generator);
|
|
}
|
|
|
|
private convertPlexResponse(
|
|
item: z.infer<typeof PlexMediaNoCollectionPlaylist>,
|
|
externalLibraryId: Maybe<string>,
|
|
): Nullable<PlexItem> {
|
|
const library = externalLibraryId
|
|
? this.findMatchingLibrary(externalLibraryId)
|
|
: null;
|
|
if (!library) {
|
|
return null;
|
|
}
|
|
|
|
const result = match(item)
|
|
.returnType<Result<PlexItem>>()
|
|
.with({ type: 'album' }, (album) =>
|
|
this.plexAlbumInjection(album, library),
|
|
)
|
|
.with({ type: 'artist' }, (artist) =>
|
|
this.plexMusicArtistInjection(artist, library),
|
|
)
|
|
.with({ type: 'episode' }, (ep) => this.plexEpisodeInjection(ep, library))
|
|
.with({ type: 'season' }, (season) =>
|
|
this.plexSeasonInjection(season, library),
|
|
)
|
|
.with({ type: 'show' }, (show) => this.plexShowInjection(show, library))
|
|
.with({ type: 'movie' }, (movie) =>
|
|
this.plexMovieInjection(movie, library),
|
|
)
|
|
.with({ type: 'track' }, (track) =>
|
|
this.plexTrackInjection(track, library),
|
|
)
|
|
.exhaustive();
|
|
if (result.isFailure()) {
|
|
this.logger.warn(result.error, `Unable to convert Plex item: %O`, item);
|
|
return null;
|
|
}
|
|
return result.get();
|
|
}
|
|
|
|
async getSubtitles(key: string): Promise<QueryResult<string>> {
|
|
try {
|
|
const subtitlesResult = await this.doGet<string>({
|
|
url: key,
|
|
});
|
|
|
|
return this.makeSuccessResult(subtitlesResult);
|
|
} catch (e) {
|
|
const err = caughtErrorToError(e);
|
|
return this.makeErrorResult('generic_request_error', err.message);
|
|
}
|
|
}
|
|
|
|
async checkServerStatus(): Promise<MediaSourceStatus> {
|
|
try {
|
|
const result = await this.doTypeCheckedGet(
|
|
'/',
|
|
PlexGenericMediaContainerResponseSchema,
|
|
);
|
|
|
|
if (result.isFailure()) {
|
|
throw result.error;
|
|
} else if (isUndefined(result)) {
|
|
// Parse error - indicates that the URL is probably not a Plex server
|
|
return {
|
|
healthy: false,
|
|
status: 'bad_response',
|
|
};
|
|
}
|
|
|
|
return {
|
|
healthy: true,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
healthy: false,
|
|
status: this.getHealthStatus(err),
|
|
} satisfies MediaSourceStatus;
|
|
}
|
|
}
|
|
|
|
async getUser() {
|
|
return this.doTypeCheckedGet('/api/v2/user', PlexUserSchema, {
|
|
baseURL: 'https://clients.plex.tv',
|
|
});
|
|
}
|
|
|
|
async getDvrs() {
|
|
try {
|
|
const result = await this.doGetPath<PlexDvrsResponse>('/livetv/dvrs');
|
|
return result?.Dvr ?? [];
|
|
} catch (err) {
|
|
this.logger.error(err, 'GET /livetv/drs failed');
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async getResources() {}
|
|
|
|
async refreshGuide(_dvrs?: PlexDvr[]) {
|
|
const dvrs = !isUndefined(_dvrs) ? _dvrs : await this.getDvrs();
|
|
if (!dvrs) {
|
|
throw new Error('Could not retrieve Plex DVRs');
|
|
}
|
|
|
|
for (const dvr of dvrs) {
|
|
await this.doPost({ url: `/livetv/dvrs/${dvr.key}/reloadGuide` });
|
|
}
|
|
}
|
|
|
|
async refreshChannels(
|
|
channels: { number: number; stealth: number; uuid: string }[],
|
|
providedDvrs?: PlexDvr[],
|
|
) {
|
|
const liveChannels = reject(channels, { stealth: 1 });
|
|
const dvrs = !isEmpty(providedDvrs) ? providedDvrs : await this.getDvrs();
|
|
if (!dvrs) {
|
|
throw new Error('Could not retrieve Plex DVRs');
|
|
}
|
|
|
|
if (isEmpty(dvrs)) {
|
|
return;
|
|
}
|
|
|
|
const qs: Record<string, number | string> = {
|
|
channelsEnabled: map(liveChannels, 'number').join(','),
|
|
};
|
|
|
|
forEach(channels, ({ number }) => {
|
|
const id = getChannelId(number);
|
|
qs[`channelMapping[${number}]`] = number;
|
|
qs[`channelMappingByKey[${number}]`] = id;
|
|
});
|
|
|
|
const keys = map(
|
|
flatMap(dvrs, ({ Device }) => Device),
|
|
(device) => device.key,
|
|
);
|
|
|
|
for (const key of keys) {
|
|
await this.doPut({
|
|
url: `/media/grabbers/devices/${key}/channelmap`,
|
|
params: qs,
|
|
});
|
|
}
|
|
}
|
|
|
|
async getDevices(): Promise<Maybe<PlexTvDevicesResponse>> {
|
|
const response = await this.doRequest<string>({
|
|
method: 'get',
|
|
baseURL: 'https://plex.tv',
|
|
url: '/devices.xml',
|
|
});
|
|
|
|
if (isError(response)) {
|
|
this.logger.error(response);
|
|
return;
|
|
}
|
|
|
|
const parsed = new XMLParser({
|
|
ignoreAttributes: false,
|
|
attributeNamePrefix: '',
|
|
}).parse(response) as PlexTvDevicesResponse;
|
|
return parsed;
|
|
}
|
|
|
|
getThumbUrl(opts: {
|
|
itemKey: string;
|
|
width?: number;
|
|
height?: number;
|
|
upscale?: string;
|
|
imageType: 'poster' | 'background';
|
|
}) {
|
|
return PlexApiClient.getImageUrl({
|
|
uri: this.options.mediaSource.uri,
|
|
accessToken: this.options.mediaSource.accessToken,
|
|
itemKey: opts.itemKey,
|
|
width: opts.width,
|
|
height: opts.height,
|
|
upscale: opts.upscale,
|
|
imageType: opts.imageType,
|
|
});
|
|
}
|
|
|
|
setEnableRequestCache(enable: boolean) {
|
|
this.options.enableRequestCache = enable;
|
|
}
|
|
|
|
protected override preRequestValidate<T>(
|
|
req: AxiosRequestConfig,
|
|
): Maybe<QueryResult<T>> {
|
|
if (isEmpty(this.options.mediaSource.accessToken)) {
|
|
return Result.failure(
|
|
QueryError.create(
|
|
'no_access_token',
|
|
'No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.',
|
|
),
|
|
);
|
|
}
|
|
return super.preRequestValidate(req);
|
|
}
|
|
|
|
static getImageUrl(opts: {
|
|
uri: string;
|
|
accessToken: string;
|
|
itemKey: string;
|
|
width?: number;
|
|
height?: number;
|
|
upscale?: string;
|
|
imageType: 'poster' | 'background';
|
|
}): string {
|
|
const {
|
|
uri,
|
|
accessToken,
|
|
itemKey,
|
|
width,
|
|
height,
|
|
upscale,
|
|
imageType = 'poster',
|
|
} = opts;
|
|
const cleanKey = itemKey.replaceAll(/\/library\/metadata\//g, '');
|
|
const path = match(imageType)
|
|
.with('poster', () => 'thumb')
|
|
.with('background', () => 'art')
|
|
.exhaustive();
|
|
|
|
let thumbUrl: URL;
|
|
const key = `/library/metadata/${cleanKey}/${path}?X-Plex-Token=${accessToken}`;
|
|
if (isUndefined(height) || isUndefined(width)) {
|
|
thumbUrl = new URL(`${uri}${key}`);
|
|
} else {
|
|
thumbUrl = new URL(`${uri}/photo/:/transcode`);
|
|
thumbUrl.searchParams.append('url', key);
|
|
thumbUrl.searchParams.append('X-Plex-Token', accessToken);
|
|
thumbUrl.searchParams.append('width', width.toString());
|
|
thumbUrl.searchParams.append('height', height.toString());
|
|
thumbUrl.searchParams.append('upscale', (upscale ?? '1').toString());
|
|
}
|
|
return thumbUrl.toString();
|
|
}
|
|
|
|
private plexShowInjection(
|
|
plexShow: ApiPlexTvShow,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexShow> {
|
|
return Result.success({
|
|
uuid: v4(),
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexShow),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
sourceType: MediaSourceType.Plex,
|
|
title: plexShow.title,
|
|
type: ProgramGroupingType.Show,
|
|
year: plexShow.year ?? null,
|
|
releaseDate: plexShow.originallyAvailableAt
|
|
? Result.attempt(
|
|
() => +dayjs(plexShow.originallyAvailableAt, 'YYYY-MM-DD'),
|
|
).orNull()
|
|
: null,
|
|
releaseDateString: plexShow.originallyAvailableAt ?? null,
|
|
actors: [],
|
|
genres: plexJoinItemInject(plexShow.Genre),
|
|
plot: plexShow.summary ?? null,
|
|
studios: isNonEmptyString(plexShow.studio)
|
|
? [{ name: plexShow.studio }]
|
|
: [],
|
|
rating: plexShow.contentRating ?? null,
|
|
summary: null,
|
|
tagline: plexShow.tagline ?? null,
|
|
identifiers: [
|
|
{
|
|
type: 'plex',
|
|
id: plexShow.ratingKey,
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
type: 'plex-guid',
|
|
id: plexShow.guid,
|
|
},
|
|
...seq.collect(plexShow.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
tags: [],
|
|
externalId: plexShow.ratingKey,
|
|
childCount: plexShow.childCount,
|
|
grandchildCount: plexShow.leafCount,
|
|
sortTitle: titleToSortTitle(plexShow.title),
|
|
} satisfies PlexShow);
|
|
}
|
|
|
|
private plexSeasonInjection(
|
|
plexSeason: ApiPlexTvSeason,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexSeason> {
|
|
return Result.success({
|
|
uuid: v4(),
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexSeason),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
sourceType: MediaSourceType.Plex,
|
|
title: plexSeason.title,
|
|
sortTitle: titleToSortTitle(plexSeason.title),
|
|
type: ProgramGroupingType.Season,
|
|
index: plexSeason.index,
|
|
releaseDate: null,
|
|
releaseDateString: null,
|
|
plot: plexSeason.summary ?? null,
|
|
studios: isNonEmptyString(plexSeason.parentStudio)
|
|
? [{ name: plexSeason.parentStudio }]
|
|
: [],
|
|
summary: null,
|
|
tagline: null,
|
|
year: null,
|
|
identifiers: [
|
|
{
|
|
type: 'plex',
|
|
id: plexSeason.ratingKey,
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
type: 'plex-guid',
|
|
id: plexSeason.guid,
|
|
},
|
|
...seq.collect(plexSeason.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
tags: [],
|
|
externalId: plexSeason.ratingKey,
|
|
childCount: plexSeason.leafCount,
|
|
show: plexSeason.parentRatingKey
|
|
? ({
|
|
sortTitle: plexSeason.parentTitle
|
|
? titleToSortTitle(plexSeason.parentTitle)
|
|
: '',
|
|
externalId: plexSeason.parentRatingKey,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
identifiers: compact([
|
|
plexSeason.parentRatingKey
|
|
? {
|
|
id: plexSeason.parentRatingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
}
|
|
: null,
|
|
plexSeason.parentGuid
|
|
? {
|
|
id: plexSeason.parentGuid,
|
|
type: 'plex-guid',
|
|
}
|
|
: null,
|
|
]),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
plot: null,
|
|
releaseDate: null,
|
|
releaseDateString: null,
|
|
studios: [],
|
|
sourceType: 'plex',
|
|
title: plexSeason.parentTitle ?? '',
|
|
summary: null,
|
|
tagline: null,
|
|
tags: [],
|
|
uuid: v4(),
|
|
type: 'show',
|
|
year: null,
|
|
canonicalId: '???',
|
|
genres: [],
|
|
actors: [],
|
|
rating: null,
|
|
} satisfies PlexShow)
|
|
: undefined,
|
|
} satisfies PlexSeason);
|
|
}
|
|
|
|
private plexEpisodeInjection(
|
|
plexEpisode: ApiPlexEpisode,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexEpisode> {
|
|
if (isNil(plexEpisode.duration) || plexEpisode.duration <= 0) {
|
|
return Result.forError(
|
|
new Error(
|
|
`Plex episode ID = ${plexEpisode.ratingKey} has invalid duration.`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isNil(plexEpisode.Media) || isEmpty(plexEpisode.Media)) {
|
|
return Result.forError(
|
|
new Error(
|
|
`Plex episode ID = ${plexEpisode.ratingKey} has no Media streams`,
|
|
),
|
|
);
|
|
}
|
|
|
|
const actors =
|
|
plexEpisode.Role?.map(({ tag, role }) => ({ name: tag, role })) ?? [];
|
|
const directors =
|
|
plexEpisode.Director?.map(({ tag }) => ({ name: tag })) ?? [];
|
|
const writers = plexEpisode.Writer?.map(({ tag }) => ({ name: tag })) ?? [];
|
|
|
|
const episode: PlexEpisode = {
|
|
uuid: v4(),
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexEpisode),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
type: ProgramType.Episode,
|
|
sourceType: MediaSourceType.Plex,
|
|
title: plexEpisode.title,
|
|
sortTitle: titleToSortTitle(plexEpisode.title),
|
|
originalTitle: null,
|
|
year: null,
|
|
summary: plexEpisode.summary ?? null,
|
|
duration: plexEpisode.duration,
|
|
actors,
|
|
directors,
|
|
writers,
|
|
episodeNumber: plexEpisode.index ?? 0,
|
|
mediaItem: plexMediaStreamsInject(
|
|
plexEpisode.ratingKey,
|
|
plexEpisode,
|
|
).getOrElse(() => emptyMediaItem(plexEpisode)),
|
|
genres: [],
|
|
releaseDate: plexEpisode.originallyAvailableAt
|
|
? +dayjs(plexEpisode.originallyAvailableAt, 'YYYY-MM-DD')
|
|
: null,
|
|
releaseDateString: plexEpisode.originallyAvailableAt ?? null,
|
|
studios: [],
|
|
identifiers: [
|
|
{
|
|
id: plexEpisode.ratingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
id: plexEpisode.guid,
|
|
type: 'plex-guid',
|
|
},
|
|
...seq.collect(plexEpisode.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
tags: [],
|
|
externalId: plexEpisode.ratingKey,
|
|
season: plexEpisode.parentRatingKey
|
|
? {
|
|
externalId: plexEpisode.parentRatingKey,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
identifiers: compact([
|
|
plexEpisode.parentRatingKey
|
|
? {
|
|
id: plexEpisode.parentRatingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
}
|
|
: null,
|
|
plexEpisode.parentGuid
|
|
? {
|
|
id: plexEpisode.parentGuid,
|
|
type: 'plex-guid',
|
|
}
|
|
: null,
|
|
]),
|
|
index: plexEpisode.parentIndex ?? 0,
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
plot: null,
|
|
releaseDate: null,
|
|
releaseDateString: null,
|
|
studios: [],
|
|
sourceType: 'plex',
|
|
title: plexEpisode.parentTitle ?? '',
|
|
sortTitle: plexEpisode.parentTitle
|
|
? titleToSortTitle(plexEpisode.parentTitle)
|
|
: '',
|
|
summary: null,
|
|
tagline: null,
|
|
tags: [],
|
|
uuid: v4(),
|
|
type: 'season',
|
|
year: null,
|
|
canonicalId: '???',
|
|
show: plexEpisode.grandparentRatingKey
|
|
? ({
|
|
externalId: plexEpisode.grandparentRatingKey,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
identifiers: compact([
|
|
plexEpisode.grandparentRatingKey
|
|
? {
|
|
id: plexEpisode.grandparentRatingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
}
|
|
: null,
|
|
plexEpisode.grandparentGuid
|
|
? {
|
|
id: plexEpisode.grandparentGuid,
|
|
type: 'plex-guid',
|
|
}
|
|
: null,
|
|
]),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
plot: null,
|
|
releaseDate: null,
|
|
releaseDateString: null,
|
|
studios: [],
|
|
sourceType: 'plex',
|
|
title: plexEpisode.grandparentTitle ?? '',
|
|
sortTitle: plexEpisode.grandparentTitle
|
|
? titleToSortTitle(plexEpisode.grandparentTitle)
|
|
: '',
|
|
summary: null,
|
|
tagline: null,
|
|
tags: [],
|
|
uuid: v4(),
|
|
type: 'show',
|
|
year: null,
|
|
canonicalId: '???',
|
|
genres: [],
|
|
actors: [],
|
|
rating: null,
|
|
} satisfies PlexShow)
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
return Result.success(episode);
|
|
}
|
|
|
|
private plexMovieInjection(
|
|
plexMovie: ApiPlexMovie,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexMovie> {
|
|
if (isNil(plexMovie.duration) || plexMovie.duration <= 0) {
|
|
return Result.forError(
|
|
new Error(
|
|
`Plex movie ID = ${plexMovie.ratingKey} has invalid duration.`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isNil(plexMovie.Media) || isEmpty(plexMovie.Media)) {
|
|
return Result.forError(
|
|
new Error(
|
|
`Plex movie ID = ${plexMovie.ratingKey} has no Media streams`,
|
|
),
|
|
);
|
|
}
|
|
|
|
const actors =
|
|
plexMovie.Role?.map(({ tag, role }) => ({ name: tag, role })) ?? [];
|
|
const directors =
|
|
plexMovie.Director?.map(({ tag }) => ({ name: tag })) ?? [];
|
|
const writers = plexMovie.Writer?.map(({ tag }) => ({ name: tag })) ?? [];
|
|
const studios = isNonEmptyString(plexMovie.studio)
|
|
? [{ name: plexMovie.studio }]
|
|
: [];
|
|
|
|
return Result.success({
|
|
uuid: v4(),
|
|
type: ProgramType.Movie,
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexMovie),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
sourceType: MediaSourceType.Plex,
|
|
title: plexMovie.title,
|
|
sortTitle: titleToSortTitle(plexMovie.title),
|
|
originalTitle: null,
|
|
year: plexMovie.year ?? null,
|
|
releaseDate: plexMovie.originallyAvailableAt
|
|
? +dayjs(plexMovie.originallyAvailableAt, 'YYYY-MM-DD')
|
|
: null,
|
|
releaseDateString: plexMovie.originallyAvailableAt ?? null,
|
|
mediaItem: plexMediaStreamsInject(
|
|
plexMovie.ratingKey,
|
|
plexMovie,
|
|
).getOrElse(() => emptyMediaItem(plexMovie)),
|
|
duration: plexMovie.duration,
|
|
actors,
|
|
directors,
|
|
writers,
|
|
studios,
|
|
genres: plexMovie.Genre?.map(({ tag }) => ({ name: tag })) ?? [],
|
|
summary: plexMovie.summary ?? null,
|
|
plot: null,
|
|
tagline: plexMovie.tagline ?? null,
|
|
rating: plexMovie.contentRating ?? null,
|
|
tags: [],
|
|
externalId: plexMovie.ratingKey,
|
|
identifiers: [
|
|
{
|
|
id: plexMovie.ratingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
id: plexMovie.guid,
|
|
type: 'plex-guid',
|
|
},
|
|
...seq.collect(plexMovie.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
private plexOtherVideoInjection(
|
|
plexClip: ApiPlexMovie,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexOtherVideo> {
|
|
if (isNil(plexClip.duration) || plexClip.duration <= 0) {
|
|
return Result.forError(
|
|
new Error(
|
|
`Plex movie ID = ${plexClip.ratingKey} has invalid duration.`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isNil(plexClip.Media) || isEmpty(plexClip.Media)) {
|
|
return Result.forError(
|
|
new Error(`Plex movie ID = ${plexClip.ratingKey} has no Media streams`),
|
|
);
|
|
}
|
|
|
|
const actors =
|
|
plexClip.Role?.map(({ tag, role }) => ({ name: tag, role })) ?? [];
|
|
const directors =
|
|
plexClip.Director?.map(({ tag }) => ({ name: tag })) ?? [];
|
|
const writers = plexClip.Writer?.map(({ tag }) => ({ name: tag })) ?? [];
|
|
const studios = isNonEmptyString(plexClip.studio)
|
|
? [{ name: plexClip.studio }]
|
|
: [];
|
|
|
|
return Result.success({
|
|
uuid: v4(),
|
|
type: ProgramType.OtherVideo,
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexClip),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
sourceType: MediaSourceType.Plex,
|
|
externalKey: plexClip.ratingKey,
|
|
title: plexClip.title,
|
|
sortTitle: titleToSortTitle(plexClip.title),
|
|
originalTitle: null,
|
|
year: plexClip.year ?? null,
|
|
releaseDate: plexClip.originallyAvailableAt
|
|
? +dayjs(plexClip.originallyAvailableAt, 'YYYY-MM-DD')
|
|
: null,
|
|
releaseDateString: plexClip.originallyAvailableAt ?? null,
|
|
mediaItem: plexMediaStreamsInject(plexClip.ratingKey, plexClip).getOrElse(
|
|
() => emptyMediaItem(plexClip),
|
|
),
|
|
duration: plexClip.duration,
|
|
actors,
|
|
directors,
|
|
writers,
|
|
studios,
|
|
genres: plexClip.Genre?.map(({ tag }) => ({ name: tag })) ?? [],
|
|
summary: plexClip.summary ?? null,
|
|
plot: null,
|
|
tagline: plexClip.tagline ?? null,
|
|
rating: plexClip.contentRating ?? null,
|
|
tags: [],
|
|
externalId: plexClip.ratingKey,
|
|
identifiers: [
|
|
{
|
|
id: plexClip.ratingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
id: plexClip.guid,
|
|
type: 'plex-guid',
|
|
},
|
|
...seq.collect(plexClip.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
private plexMusicArtistInjection(
|
|
plexArtist: ApiPlexMusicArtist,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexArtist> {
|
|
return Result.success({
|
|
uuid: v4(),
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexArtist),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
sourceType: MediaSourceType.Plex,
|
|
title: plexArtist.title,
|
|
sortTitle: titleToSortTitle(plexArtist.title),
|
|
type: ProgramGroupingType.Artist,
|
|
tagline: null,
|
|
genres: plexJoinItemInject(plexArtist.Genre),
|
|
summary: plexArtist.summary ?? null,
|
|
plot: null,
|
|
identifiers: [
|
|
{
|
|
type: 'plex',
|
|
id: plexArtist.ratingKey,
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
type: 'plex-guid',
|
|
id: plexArtist.guid,
|
|
},
|
|
...seq.collect(plexArtist.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
tags: [],
|
|
externalId: plexArtist.ratingKey,
|
|
} satisfies PlexArtist);
|
|
}
|
|
|
|
private plexAlbumInjection(
|
|
plexAlbum: ApiPlexMusicAlbum,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexAlbum> {
|
|
return Result.success({
|
|
uuid: v4(),
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexAlbum),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
sourceType: MediaSourceType.Plex,
|
|
title: plexAlbum.title,
|
|
sortTitle: titleToSortTitle(plexAlbum.title),
|
|
type: ProgramGroupingType.Album,
|
|
index: plexAlbum.index,
|
|
genres: plexJoinItemInject(plexAlbum.Genre),
|
|
plot: plexAlbum.summary ?? null,
|
|
studios: isNonEmptyString(plexAlbum.studio)
|
|
? [{ name: plexAlbum.studio }]
|
|
: [],
|
|
summary: null,
|
|
tagline: null,
|
|
year: null,
|
|
identifiers: [
|
|
{
|
|
type: 'plex',
|
|
id: plexAlbum.ratingKey,
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
type: 'plex-guid',
|
|
id: plexAlbum.guid,
|
|
},
|
|
...seq.collect(plexAlbum.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
tags: [],
|
|
externalId: plexAlbum.ratingKey,
|
|
releaseDate: plexAlbum.originallyAvailableAt
|
|
? +dayjs(plexAlbum.originallyAvailableAt)
|
|
: null,
|
|
releaseDateString: plexAlbum.originallyAvailableAt ?? null,
|
|
});
|
|
}
|
|
|
|
private plexTrackInjection(
|
|
plexTrack: ApiPlexMusicTrack,
|
|
mediaLibrary: MediaSourceLibraryOrm,
|
|
): Result<PlexTrack, WrappedError> {
|
|
if (isNil(plexTrack.duration) || plexTrack.duration <= 0) {
|
|
return Result.forError(
|
|
new Error(
|
|
`Plex track ID = ${plexTrack.ratingKey} has invalid duration.`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isNil(plexTrack.Media) || isEmpty(plexTrack.Media)) {
|
|
return Result.forError(
|
|
new Error(
|
|
`Plex track ID = ${plexTrack.ratingKey} has no Media streams`,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Result.success({
|
|
uuid: v4(),
|
|
canonicalId: this.canonicalizer.getCanonicalId(plexTrack),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
type: ProgramType.Track,
|
|
sourceType: MediaSourceType.Plex,
|
|
title: plexTrack.title,
|
|
sortTitle: titleToSortTitle(plexTrack.title),
|
|
originalTitle: null,
|
|
year: plexTrack.parentYear ?? null,
|
|
duration: plexTrack.duration ?? 0,
|
|
actors: [],
|
|
directors: [],
|
|
writers: [],
|
|
genres: [],
|
|
trackNumber: plexTrack.index ?? 0,
|
|
mediaItem: plexMediaStreamsInject(
|
|
plexTrack.ratingKey,
|
|
plexTrack,
|
|
).getOrElse(() => emptyMediaItem(plexTrack)),
|
|
// TODO:
|
|
// genres: plexJoinItemInject(plexTrack.Genre),
|
|
releaseDate: null,
|
|
releaseDateString: null,
|
|
studios: [],
|
|
identifiers: [
|
|
{
|
|
id: plexTrack.ratingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
},
|
|
{
|
|
id: plexTrack.guid,
|
|
type: 'plex-guid',
|
|
},
|
|
...seq.collect(plexTrack.Guid, (guid) => {
|
|
const parsed = parsePlexGuid(guid.id);
|
|
if (!parsed) return;
|
|
return {
|
|
id: parsed.externalKey,
|
|
type: parsed.sourceType,
|
|
};
|
|
}),
|
|
],
|
|
tags: [],
|
|
externalId: plexTrack.ratingKey,
|
|
album: plexTrack.parentRatingKey
|
|
? {
|
|
externalId: plexTrack.parentRatingKey,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
identifiers: compact([
|
|
plexTrack.parentRatingKey
|
|
? {
|
|
id: plexTrack.parentRatingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
}
|
|
: null,
|
|
plexTrack.parentGuid
|
|
? {
|
|
id: plexTrack.parentGuid,
|
|
type: 'plex-guid',
|
|
}
|
|
: null,
|
|
]),
|
|
index: plexTrack.parentIndex ?? 0,
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
plot: null,
|
|
releaseDate: null,
|
|
releaseDateString: null,
|
|
studios: [],
|
|
sourceType: 'plex',
|
|
title: plexTrack.parentTitle ?? '',
|
|
sortTitle: plexTrack.parentTitle
|
|
? titleToSortTitle(plexTrack.parentTitle)
|
|
: '',
|
|
summary: null,
|
|
tagline: null,
|
|
tags: [],
|
|
uuid: v4(),
|
|
type: 'album',
|
|
year: null,
|
|
canonicalId: '???',
|
|
artist: plexTrack.grandparentRatingKey
|
|
? ({
|
|
externalId: plexTrack.grandparentRatingKey,
|
|
externalLibraryId: mediaLibrary.externalKey,
|
|
identifiers: compact([
|
|
plexTrack.grandparentRatingKey
|
|
? {
|
|
id: plexTrack.grandparentRatingKey,
|
|
type: 'plex',
|
|
sourceId: this.options.mediaSource.uuid,
|
|
}
|
|
: null,
|
|
plexTrack.grandparentGuid
|
|
? {
|
|
id: plexTrack.grandparentGuid,
|
|
type: 'plex-guid',
|
|
}
|
|
: null,
|
|
]),
|
|
mediaSourceId: this.options.mediaSource.uuid,
|
|
libraryId: mediaLibrary.uuid,
|
|
plot: null,
|
|
sourceType: 'plex',
|
|
title: plexTrack.grandparentTitle ?? '',
|
|
sortTitle: plexTrack.grandparentTitle
|
|
? titleToSortTitle(plexTrack.grandparentTitle)
|
|
: '',
|
|
summary: null,
|
|
tagline: null,
|
|
tags: [],
|
|
uuid: v4(),
|
|
type: 'artist',
|
|
canonicalId: '???',
|
|
genres: [],
|
|
} satisfies PlexArtist)
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
} satisfies PlexTrack);
|
|
}
|
|
}
|
|
|
|
type PlexTvDevicesResponse = {
|
|
MediaContainer: { Device: PlexResource[] };
|
|
};
|
|
|
|
function plexJoinItemInject(items: Nilable<PlexJoinItem[]>): NamedEntity[] {
|
|
return items?.map(({ tag }) => ({ name: tag })) ?? [];
|
|
}
|
|
|
|
function emptyMediaItem(item: PlexTerminalMedia): Maybe<MediaItem> {
|
|
const media = maxBy(
|
|
item.Media?.filter((m) => (m.Part?.length ?? 0) > 0),
|
|
(m) => m.id,
|
|
)!;
|
|
const part = media.Part[0];
|
|
|
|
const duration = part.duration ?? media.duration;
|
|
|
|
if (isNil(duration) || duration <= 0) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
displayAspectRatio: '',
|
|
duration,
|
|
sampleAspectRatio: '',
|
|
streams: [],
|
|
resolution: { widthPx: media.width!, heightPx: media.height! },
|
|
locations: [
|
|
{
|
|
externalKey: part.key,
|
|
path: part.file,
|
|
sourceType: MediaSourceType.Plex,
|
|
type: 'remote',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
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 = {
|
|
// sampleAspectRatio: isNonEmptyString(videoStream?.pixelAspectRatio)
|
|
// ? videoStream.pixelAspectRatio
|
|
// : '1:1',
|
|
// scanType:
|
|
// videoStream.scanType === 'interlaced'
|
|
// ? 'interlaced'
|
|
// : videoStream.scanType === 'progressive'
|
|
// ? 'progressive'
|
|
// : 'unknown',
|
|
// width: videoStream.width,
|
|
// height: videoStream.height,
|
|
// frameRate: videoStream.frameRate,
|
|
// displayAspectRatio:
|
|
// (relevantMedia?.aspectRatio ?? 0) === 0
|
|
// ? ''
|
|
// : round(relevantMedia?.aspectRatio ?? 0.0, 10).toFixed(),
|
|
// chapters
|
|
// anamorphic:
|
|
// videoStream.anamorphic === '1' || videoStream.anamorphic === true,
|
|
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,
|
|
// streamIndex: videoStream.index?.toString() ?? '0',
|
|
} 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: 'intro',
|
|
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,
|
|
});
|
|
}
|