diff --git a/backend/package-lock.json b/backend/package-lock.json index f281c2e..31b37a3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10510,7 +10510,7 @@ }, "packages/reiverr-plugin": { "name": "@aleksilassila/reiverr-plugin", - "version": "3.0.0", + "version": "4.0.0", "license": "ISC", "devDependencies": {} }, diff --git a/backend/packages/jellyfin.plugin/src/index.ts b/backend/packages/jellyfin.plugin/src/index.ts index 96c857b..306f1e1 100644 --- a/backend/packages/jellyfin.plugin/src/index.ts +++ b/backend/packages/jellyfin.plugin/src/index.ts @@ -1,594 +1,106 @@ import { - CatalogueItem, - CatalogueProvider, - DirectionOption, - EpisodeMetadata, - MovieMetadata, - OrderOption, - PaginatedResponse, - PaginationParams, - PlaybackConfig, - PluginProvider, - SettingsManager, - SourceProvider, - SourceProviderError, - Stream, - StreamCandidate, - Subtitles, + MediaSourceProvider, + ReiverrPlugin, + SourceProviderSettingsTemplate, UserContext, + ValidationResponse, } from '@aleksilassila/reiverr-plugin'; -import { Readable } from 'stream'; -import { - BaseItemKind, - ItemFields, - ItemSortBy, - SortOrder, -} from './jellyfin.openapi'; -import { JellyfinSettings, PluginContext } from './plugin-context'; -import { JellyfinSettingsManager } from './settings'; -import { - bitrateQualities, - formatSize, - formatTicksToTime, - getClosestBitrate, - JELLYFIN_DEVICE_ID, -} from './utils'; +import { JellyfinMediaSourceProvider } from './media-source-provider'; -export default class JellyfinPluginProvider extends PluginProvider { - getPlugins(): SourceProvider[] { - return [new JellyfinProvider()]; - } -} - -async function getLibraryItems(context: PluginContext) { - return context.api.items - .getItems({ - userId: context.settings.userId, - // hasTmdbId: true, - recursive: true, - includeItemTypes: [ - BaseItemKind.Movie, - BaseItemKind.Series, - BaseItemKind.Episode, - ], - fields: [ - ItemFields.ProviderIds, - ItemFields.Genres, - ItemFields.DateLastMediaAdded, - ItemFields.DateCreated, - ItemFields.MediaSources, - ], - }) - .then((res) => res.data.Items ?? []); -} - -export class JellyfinCatalogueProvider implements CatalogueProvider { - getOrderOptions?: () => Promise = async () => { - const directions: DirectionOption[] = [ - { - label: 'Ascending', - value: 'asc', - }, - { - label: 'Descending', - value: 'desc', - }, - ]; - - return [ - { - label: 'Title', - value: 'title', - directions, - }, - { - label: 'Date Added', - value: 'date-added', - directions, - }, - { - label: 'Date Created', - value: 'date-created', - directions, - }, - ]; - }; - - getMovieCatalogue?: (options: { - context: UserContext; - pagination: PaginationParams; - order?: string; - direction?: string; - }) => Promise> = async (options) => { - const { context, pagination, order, direction } = options; - - const sortBy: ItemSortBy[] = []; - - if (order === 'title') { - sortBy.push(ItemSortBy.Name); - } else if (order === 'date-added') { - sortBy.push(ItemSortBy.DateLastContentAdded); - } else if (order === 'date-created') { - sortBy.push(ItemSortBy.DateCreated); - } - // const items = ( - // await getLibraryItems(new PluginContext(context.settings, context.token)) - // ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie'); - const data = await new PluginContext( - context.settings, - context.token, - ).api.items - .getItems({ - userId: context.settings.userId, - hasTmdbId: true, - recursive: true, - includeItemTypes: [BaseItemKind.Movie], - fields: [ - ItemFields.ProviderIds, - ItemFields.Genres, - ItemFields.DateLastMediaAdded, - ItemFields.DateCreated, - ItemFields.MediaSources, - ], - sortBy, - sortOrder: [ - direction === 'asc' ? SortOrder.Ascending : SortOrder.Descending, - ], - startIndex: (pagination.page - 1) * pagination.itemsPerPage, - limit: pagination.itemsPerPage, - }) - .then((res) => res.data); - - return { - total: data.TotalRecordCount ?? data.Items?.length ?? 0, - page: pagination.page, - itemsPerPage: pagination.itemsPerPage, - items: - data?.Items?.map((item) => ({ - id: item.ProviderIds?.Tmdb, - tmdbId: item.ProviderIds?.Tmdb, - mediaType: 'movie' as const, - })) ?? [], - }; - }; - - getSeriesCatalogue?: (options: { - context: UserContext; - pagination: PaginationParams; - order?: string; - direction?: 'asc' | 'desc'; - }) => Promise> = async (options) => { - const { context, pagination, order, direction } = options; - - const sortBy: ItemSortBy[] = []; - - if (order === 'title') { - sortBy.push(ItemSortBy.Name); - } else if (order === 'date-added') { - sortBy.push(ItemSortBy.DateLastContentAdded); - } else if (order === 'date-created') { - sortBy.push(ItemSortBy.DateCreated); - } - - const data = await new PluginContext( - context.settings, - context.token, - ).api.items - .getItems({ - userId: context.settings.userId, - hasTmdbId: true, - recursive: true, - includeItemTypes: [BaseItemKind.Series], - fields: [ - ItemFields.ProviderIds, - ItemFields.Genres, - ItemFields.DateLastMediaAdded, - ItemFields.DateCreated, - ItemFields.MediaSources, - ], - sortBy, - sortOrder: [ - direction === 'asc' ? SortOrder.Ascending : SortOrder.Descending, - ], - startIndex: (pagination.page - 1) * pagination.itemsPerPage, - limit: pagination.itemsPerPage, - }) - .then((res) => res.data); - - return { - total: data.TotalRecordCount ?? data.Items?.length ?? 0, - page: pagination.page, - itemsPerPage: pagination.itemsPerPage, - items: - data?.Items?.map((item) => ({ - id: item.ProviderIds?.Tmdb, - tmdbId: item.ProviderIds?.Tmdb, - mediaType: 'series' as const, - })) ?? [], - }; - }; -} - -class JellyfinProvider extends SourceProvider { +class JellyfinPlugin extends ReiverrPlugin { name: string = 'jellyfin'; - private getProxyUrl(sourceId: string) { - return `/api/sources/${sourceId}/proxy`; - } + getMediaSourceProvider: (userContext: UserContext) => MediaSourceProvider = ( + userContext, + ) => new JellyfinMediaSourceProvider(userContext); - settingsManager: SettingsManager = new JellyfinSettingsManager(); + getSettingsTemplate: () => SourceProviderSettingsTemplate = () => ({ + baseUrl: { + type: 'string', + label: 'Base URL', + placeholder: 'http://localhost:8096', + required: true, + }, + apiKey: { + type: 'password', + label: 'API Key', + placeholder: '', + required: true, + }, + userId: { + type: 'string', + label: 'Username or User ID', + placeholder: 'username or user id', + required: true, + }, + }); - catalogueProvider: CatalogueProvider = new JellyfinCatalogueProvider(); + validateSettings: (options: { + settings: Record; + }) => Promise = async ({ settings }) => { + let isValid = true; + const errors = { + baseUrl: '', + apiKey: '', + userId: '', + }; + const replace: Record = {}; - getMovieStreams = async ( - tmdbId: string, - metadata: MovieMetadata, - context: UserContext, - config?: PlaybackConfig, - ): Promise<{ candidates: StreamCandidate[] }> => { - return this.getMovieStream(tmdbId, metadata, '', context, config) - .then((stream) => ({ candidates: [stream] })) - .catch((e) => { - if (e === SourceProviderError.StreamNotFound) { - return { candidates: [] }; - } else throw e; - }); - }; - - getEpisodeStreams = async ( - tmdbId: string, - metadata: EpisodeMetadata, - context: UserContext, - config?: PlaybackConfig, - ): Promise<{ candidates: StreamCandidate[] }> => { - return this.getEpisodeStream(tmdbId, metadata, '', context, config) - .then((stream) => ({ candidates: [stream] })) - .catch((e) => { - if (e === SourceProviderError.StreamNotFound) { - return { candidates: [] }; - } else throw e; - }); - }; - - getMovieStream = async ( - tmdbId: string, - metadata: MovieMetadata, - key: string, - userContext: UserContext, - config?: PlaybackConfig, - ): Promise => { - const context = new PluginContext(userContext.settings, userContext.token); - const items = await getLibraryItems(context); - - const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId); - - // console.log(items.map((item) => item)) - - if (!movie || !movie.MediaSources || movie.MediaSources.length === 0) { - throw SourceProviderError.StreamNotFound; + if (!settings.baseUrl) { + isValid = false; + errors.baseUrl = 'Base URL is required'; } - /* - await jellyfinApi.getPlaybackInfo( - id, - getDeviceProfile(), - options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0, - options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate, - audioStreamIndex - ); - */ + if (!settings.apiKey) { + isValid = false; + errors.apiKey = 'API Key is required'; + } - const startTimeTicks = movie.RunTimeTicks - ? Math.floor(movie.RunTimeTicks * config?.progress) - : undefined; - const maxStreamingBitrate = config?.bitrate || 0; //|| movie.MediaSources?.[0]?.Bitrate || 10000000 + if (!settings.userId) { + isValid = false; + errors.userId = 'User ID is required'; + } - const playbackInfo = await context.api.items.getPostedPlaybackInfo( - movie.Id, - { - DeviceProfile: config?.deviceProfile, - }, - { - userId: context.settings.userId, - startTimeTicks: startTimeTicks || 0, - ...(maxStreamingBitrate ? { maxStreamingBitrate } : {}), - autoOpenLiveStream: true, - ...(config?.audioStreamIndex - ? { audioStreamIndex: config?.audioStreamIndex } - : {}), - mediaSourceId: movie.Id, + if (isValid) { + const mediaSourceProvider = new JellyfinMediaSourceProvider({ + settings, + token: '', + sourceId: '', + userId: '', + }); + let [user, err] = await mediaSourceProvider.api.users + .getUserById(settings.userId, { timeout: 5000 }) + .then((res) => [res.data, undefined]) + .catch((err) => [undefined, err.message]); - // deviceId: JELLYFIN_DEVICE_ID, - // mediaSourceId: movie.MediaSources[0].Id, - // maxBitrate: 8000000, - }, - ); + if (!user && err) { + [user, err] = await mediaSourceProvider.api.users + .getUsers() + .then((res) => res.data?.find((u) => u.Name === settings.userId)) + .then((user) => { + if (!user || !user.Id) { + return [undefined, 'User not found']; + } - const mediasSource = playbackInfo.data?.MediaSources?.[0]; + replace.userId = user.Id; - const playbackUri = - this.getProxyUrl(userContext.sourceId) + - (mediasSource?.TranscodingUrl || - `/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${mediasSource?.ETag}`) + - `&reiverr_token=${userContext.token}`; + return [user, undefined]; + }) + .catch((err) => [undefined, err.message]); + } - const audioStreams: Stream['audioStreams'] = - mediasSource?.MediaStreams.filter((s) => s.Type === 'Audio').map((s) => ({ - bitrate: s.BitRate, - label: s.Language, - codec: s.Codec, - index: s.Index, - })) ?? []; - - const qualities: Stream['qualities'] = [ - ...bitrateQualities, - { - bitrate: mediasSource.Bitrate, - label: 'Original', - codec: undefined, - original: true, - }, - ].map((q, i) => ({ - ...q, - index: i, - })); - - const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate); - - const subtitles: Subtitles[] = mediasSource.MediaStreams.filter( - (s) => s.Type === 'Subtitle' && s.DeliveryUrl, - ).map((s, i) => ({ - src: - this.getProxyUrl(userContext.sourceId) + - `${s.DeliveryUrl}&reiverr_token=${userContext.token}`, - lang: s.Language, - kind: 'subtitles', - label: s.DisplayTitle, - })); + if (!user && err) { + isValid = false; + errors.userId = `Could not get user: ${err}`; + } + } return { - key: '0', - title: movie.Name, - properties: [ - { - label: 'Video', - value: mediasSource.Bitrate || 0, - formatted: - mediasSource.MediaStreams.find((s) => s.Type === 'Video') - ?.DisplayTitle || 'Unknown', - }, - { - label: 'Size', - value: mediasSource.Size, - formatted: formatSize(mediasSource.Size), - }, - { - label: 'Filename', - value: mediasSource.Name, - formatted: undefined, - }, - { - label: 'Runtime', - value: mediasSource.RunTimeTicks, - formatted: formatTicksToTime(mediasSource.RunTimeTicks), - }, - ], - audioStreamIndex: - config?.audioStreamIndex ?? - mediasSource?.DefaultAudioStreamIndex ?? - audioStreams[0].index, - audioStreams, - duration: mediasSource.RunTimeTicks - ? mediasSource.RunTimeTicks / 10_000_000 - : 0, - progress: config?.progress ?? 0, - qualities, - qualityIndex: getClosestBitrate(qualities, bitrate).index, - subtitles, - src: playbackUri, - // uri: - // proxyUrl + - // '/stream_new2/H4sIAAAAAAAAAw3OWXKDIAAA0Cvhggn9TBqSuJARBcU_CloiYp2Ojcvpm3eCB2EXASWjIAwRUkd4AF7XdYdQAY0kVPIjDTghrElZT0EJqGlv5I_64V5UOk58vOSO7F8bcjKYnvmusRg0zLe5Lv2YaWsSUpFMuTXOAAS5O66s_H5RBpbWrmftnV4JuIdZ8LNrf1laHs_FTqkMmro4z7CsSS7sRNpx2liFotJ5TPY45Q6tms3R45NSdYWGWZ6yvTm14.lXAV7r67IyOy85n5JHjQeFzV0z0guHo2YcrCzQQoEumgIZxrlQgQir2m4suLyPK22t6eX7nmG.Sn8SxRNdH7dBNKMxxGucvgyj8Lind4D.AeRg7d1BAQAA/master.m3u8' + - // `?reiverr_token=${userContext.token}`, - directPlay: - !!mediasSource?.SupportsDirectPlay || - !!mediasSource?.SupportsDirectStream, + isValid, + errors, + settings: replace, }; }; - - getEpisodeStream = async ( - tmdbId: string, - metadata: EpisodeMetadata, - key: string, - userContext: UserContext, - config?: PlaybackConfig, - ): Promise => { - const context = new PluginContext(userContext.settings, userContext.token); - const items = await getLibraryItems(context); - - const show = items.find( - (item) => item.ProviderIds?.Tmdb === tmdbId, - // && item.ParentIndexNumber === seasonNumber && - // item.IndexNumber === episodeNumber, - ); - - const episode = items.find( - (item) => - item.SeriesId === show?.Id && - item.IndexNumber === metadata.episode && - item.ParentIndexNumber === metadata.season, - ); - - if ( - !episode || - !episode.MediaSources || - episode.MediaSources.length === 0 - ) { - throw SourceProviderError.StreamNotFound; - } - - /* - await jellyfinApi.getPlaybackInfo( - id, - getDeviceProfile(), - options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0, - options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate, - audioStreamIndex - ); - */ - - const startTimeTicks = episode.RunTimeTicks - ? Math.floor(episode.RunTimeTicks * (config?.progress ?? 0)) - : undefined; - const maxStreamingBitrate = config?.bitrate || 0; //|| movie.MediaSources?.[0]?.Bitrate || 10000000 - - const playbackInfo = await context.api.items.getPostedPlaybackInfo( - episode.Id, - { - DeviceProfile: config?.deviceProfile, - }, - { - userId: context.settings.userId, - startTimeTicks: startTimeTicks || 0, - ...(maxStreamingBitrate ? { maxStreamingBitrate } : {}), - autoOpenLiveStream: true, - ...(config?.audioStreamIndex - ? { audioStreamIndex: config?.audioStreamIndex } - : {}), - mediaSourceId: episode.Id, - - // deviceId: JELLYFIN_DEVICE_ID, - // mediaSourceId: movie.MediaSources[0].Id, - // maxBitrate: 8000000, - }, - ); - - const mediasSource = playbackInfo.data?.MediaSources?.[0]; - - const playbackUri = - this.getProxyUrl(userContext.sourceId) + - (mediasSource?.TranscodingUrl || - `/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${mediasSource?.ETag}`) + - `&reiverr_token=${userContext.token}`; - - const audioStreams: Stream['audioStreams'] = - mediasSource?.MediaStreams.filter((s) => s.Type === 'Audio').map((s) => ({ - bitrate: s.BitRate, - label: s.Language, - codec: s.Codec, - index: s.Index, - })) ?? []; - - const qualities: Stream['qualities'] = [ - ...bitrateQualities, - { - bitrate: mediasSource.Bitrate, - label: 'Original', - codec: undefined, - original: true, - }, - ].map((q, i) => ({ - ...q, - index: i, - })); - - const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate); - - const subtitles: Subtitles[] = mediasSource.MediaStreams.filter( - (s) => s.Type === 'Subtitle' && s.DeliveryUrl, - ).map((s, i) => ({ - src: - this.getProxyUrl(userContext.sourceId) + - `${s.DeliveryUrl}&reiverr_token=${userContext.token}`, - lang: s.Language, - kind: 'subtitles', - label: s.DisplayTitle, - })); - - return { - key: '0', - title: episode.Name, - properties: [ - { - label: 'Video', - value: mediasSource.Bitrate || 0, - formatted: - mediasSource.MediaStreams.find((s) => s.Type === 'Video') - ?.DisplayTitle || 'Unknown', - }, - { - label: 'Size', - value: mediasSource.Size, - formatted: formatSize(mediasSource.Size), - }, - { - label: 'Filename', - value: mediasSource.Name, - formatted: undefined, - }, - { - label: 'Runtime', - value: mediasSource.RunTimeTicks, - formatted: formatTicksToTime(mediasSource.RunTimeTicks), - }, - ], - audioStreamIndex: - config?.audioStreamIndex ?? - mediasSource?.DefaultAudioStreamIndex ?? - audioStreams[0].index, - audioStreams, - duration: mediasSource.RunTimeTicks - ? mediasSource.RunTimeTicks / 10_000_000 - : 0, - progress: config?.progress ?? 0, - qualities, - qualityIndex: getClosestBitrate(qualities, bitrate).index, - subtitles, - src: playbackUri, - // uri: - // proxyUrl + - // '/stream_new2/H4sIAAAAAAAAAw3OWXKDIAAA0Cvhggn9TBqSuJARBcU_CloiYp2Ojcvpm3eCB2EXASWjIAwRUkd4AF7XdYdQAY0kVPIjDTghrElZT0EJqGlv5I_64V5UOk58vOSO7F8bcjKYnvmusRg0zLe5Lv2YaWsSUpFMuTXOAAS5O66s_H5RBpbWrmftnV4JuIdZ8LNrf1laHs_FTqkMmro4z7CsSS7sRNpx2liFotJ5TPY45Q6tms3R45NSdYWGWZ6yvTm14.lXAV7r67IyOy85n5JHjQeFzV0z0guHo2YcrCzQQoEumgIZxrlQgQir2m4suLyPK22t6eX7nmG.Sn8SxRNdH7dBNKMxxGucvgyj8Lind4D.AeRg7d1BAQAA/master.m3u8' + - // `?reiverr_token=${userContext.token}`, - directPlay: - !!mediasSource?.SupportsDirectPlay || - !!mediasSource?.SupportsDirectStream, - }; - }; - - proxyHandler = async ( - req: any, - res: any, - options: { context: UserContext; uri: string; targetUrl?: string }, - ): Promise => { - const { context, uri, targetUrl } = options; - const settings = context.settings as JellyfinSettings; - - const url = settings.baseUrl + uri; - - const headers = {}; - for (const key in req.headers) { - if (key === 'host') continue; - headers[key] = req.headers[key]; - } - - const proxyRes = await fetch(url, { - method: req.method || 'GET', - headers: { - ...headers, - Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`, - }, - }).catch((e) => { - console.error('error fetching proxy response', e); - res.status(500).send('Error fetching proxy response'); - }); - - if (!proxyRes) return; - - proxyRes.headers.forEach((value, name) => { - res.setHeader(name, value); - }); - - res.status(proxyRes.status); - Readable.from(proxyRes.body).pipe(res); - }; } + +export default new JellyfinPlugin(); diff --git a/backend/packages/jellyfin.plugin/src/media-source-provider.ts b/backend/packages/jellyfin.plugin/src/media-source-provider.ts new file mode 100644 index 0000000..0c8a2a9 --- /dev/null +++ b/backend/packages/jellyfin.plugin/src/media-source-provider.ts @@ -0,0 +1,578 @@ +import { + CatalogueItem, + DirectionOption, + MediaSourceProvider, + OrderOption, + PaginatedResponse, + PaginationParams, + PlaybackConfig, + SourceProviderError, + SourceProviderSettings, + Stream, + StreamCandidate, + Subtitles, + UserContext, +} from '@aleksilassila/reiverr-plugin'; +import { Readable } from 'stream'; +import { + BaseItemKind, + ItemFields, + ItemSortBy, + Api as JellyfinApi, + SortOrder, +} from './jellyfin.openapi'; +import { + bitrateQualities, + formatSize, + formatTicksToTime, + getClosestBitrate, + JELLYFIN_DEVICE_ID, +} from './utils'; + +export interface JellyfinSettings extends SourceProviderSettings { + apiKey: string; + baseUrl: string; + userId: string; +} + +export class JellyfinMediaSourceProvider extends MediaSourceProvider { + api: JellyfinApi; + + private getProxyUrl() { + return `/api/sources/${this.sourceId}/proxy`; + } + + constructor(userContext: UserContext) { + super(userContext); + this.api = new JellyfinApi({ + baseURL: userContext.settings.baseUrl, + headers: { + Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${userContext.settings.apiKey}"`, + }, + paramsSerializer: { + indexes: null, + }, + }); + } + + getTmdbMovieCandidates?: (options: { + tmdbMovie: any; + }) => Promise<{ candidates: StreamCandidate[] }> = async ({ tmdbMovie }) => { + const movies = await this.api.items.getItems({ + userId: this.settings.userId, + hasTmdbId: true, + recursive: true, + includeItemTypes: [BaseItemKind.Movie], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + }); + + const movie = movies.data.Items.find( + (i) => i.ProviderIds?.Tmdb === tmdbMovie.id, + ); + + if (!movie || !movie.MediaSources || movie.MediaSources.length === 0) { + throw SourceProviderError.StreamNotFound; + } + + return { + candidates: [ + { + id: movie.ProviderIds?.Tmdb, + tmdbId: movie.ProviderIds?.Tmdb, + mediaType: 'movie' as const, + streamId: movie.Id, + title: movie.Name, + properties: [ + { + label: 'Video', + value: movie.MediaSources[0].Bitrate || 0, + formatted: + movie.MediaSources[0].MediaStreams.find( + (s) => s.Type === 'Video', + )?.DisplayTitle || 'Unknown', + }, + { + label: 'Size', + value: movie.MediaSources[0].Size, + formatted: formatSize(movie.MediaSources[0].Size), + }, + { + label: 'Filename', + value: movie.MediaSources[0].Name, + formatted: undefined, + }, + { + label: 'Runtime', + value: movie.MediaSources[0].RunTimeTicks, + formatted: formatTicksToTime(movie.MediaSources[0].RunTimeTicks), + }, + ], + }, + ], + }; + }; + + getTmdbEpisodeCandidates?: (options: { + tmdbSeries: any; + tmdbEpisode: any; + }) => Promise<{ candidates: StreamCandidate[] }> = async ({ + tmdbSeries, + tmdbEpisode, + }) => { + // return this.getEpisodeStream(tmdbId, metadata, '', context, config) + // .then((stream) => ({ candidates: [stream] })) + // .catch((e) => { + // if (e === SourceProviderError.StreamNotFound) { + // return { candidates: [] }; + // } else throw e; + // }); + + const series = await this.api.items.getItems({ + userId: this.settings.userId, + hasTmdbId: true, + recursive: true, + includeItemTypes: [BaseItemKind.Series], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + }); + + const show = series.data.Items.find( + (i) => i.ProviderIds?.Tmdb === String(tmdbSeries.id), + ); + + if (!show) { + console.error( + 'series not found', + series.data?.Items?.map((i) => i.ProviderIds), + tmdbSeries, + ); + throw SourceProviderError.StreamNotFound; + } + + const episodes = await this.api.items.getItems({ + userId: this.settings.userId, + recursive: true, + includeItemTypes: [BaseItemKind.Episode], + fields: [ + // ItemFields.DateLastMediaAdded, + // ItemFields.DateCreated, + ItemFields.MediaSources, + ], + // parentId: show.Id, + parentIndexNumber: tmdbEpisode.season_number, + indexNumber: tmdbEpisode.episode_number, + }); + + const episode = episodes.data.Items.find( + (e) => + e.SeriesId === show.Id && + e.ParentIndexNumber === tmdbEpisode.season_number && + e.IndexNumber === tmdbEpisode.episode_number, + ); + + if ( + !episode || + !episode.MediaSources || + episode.MediaSources.length === 0 + ) { + console.error('episode not found', episode, episodes.data.Items.length); + throw SourceProviderError.StreamNotFound; + } + + return { + candidates: [ + { + id: episode.ProviderIds?.Tmdb, + tmdbId: episode.ProviderIds?.Tmdb, + mediaType: 'episode' as const, + streamId: episode.Id, + title: episode.Name, + properties: [ + { + label: 'Video', + value: episode.MediaSources[0].Bitrate || 0, + formatted: + episode.MediaSources[0].MediaStreams.find( + (s) => s.Type === 'Video', + )?.DisplayTitle || 'Unknown', + }, + { + label: 'Size', + value: episode.MediaSources[0].Size, + formatted: formatSize(episode.MediaSources[0].Size), + }, + { + label: 'Filename', + value: episode.MediaSources[0].Name, + formatted: undefined, + }, + { + label: 'Runtime', + value: episode.MediaSources[0].RunTimeTicks, + formatted: formatTicksToTime( + episode.MediaSources[0].RunTimeTicks, + ), + }, + ], + }, + ], + }; + }; + + getStream?: (options: { + streamId: string; + config?: PlaybackConfig; + }) => Promise = async (options) => { + const { progress, audioStreamIndex, deviceProfile } = options.config || {}; + + const movie = await this.api.items + .getItems({ + ids: [options.streamId], + userId: this.settings.userId, + // hasTmdbId: true, + recursive: true, + includeItemTypes: [ + BaseItemKind.Movie, + BaseItemKind.Series, + BaseItemKind.Episode, + ], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + }) + .then((r) => r.data.Items.find((i) => i.Id === options.streamId)); + + // console.log(items.map((item) => item)) + + if (!movie || !movie.MediaSources || movie.MediaSources.length === 0) { + throw SourceProviderError.StreamNotFound; + } + + /* + await jellyfinApi.getPlaybackInfo( + id, + getDeviceProfile(), + options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0, + options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate, + audioStreamIndex + ); + */ + + const startTimeTicks = movie.RunTimeTicks + ? Math.floor(movie.RunTimeTicks * progress) + : undefined; + const maxStreamingBitrate = options?.config?.bitrate ?? 0; //|| movie.MediaSources?.[0]?.Bitrate || 10000000 + + const playbackInfo = await this.api.items.getPostedPlaybackInfo( + movie.Id, + { + DeviceProfile: deviceProfile, + }, + { + userId: this.settings.userId, + startTimeTicks: startTimeTicks || 0, + ...(maxStreamingBitrate ? { maxStreamingBitrate } : {}), + autoOpenLiveStream: true, + ...(audioStreamIndex ? { audioStreamIndex } : {}), + mediaSourceId: movie.Id, + + // deviceId: JELLYFIN_DEVICE_ID, + // mediaSourceId: movie.MediaSources[0].Id, + // maxBitrate: 8000000, + }, + ); + + const mediasSource = playbackInfo.data?.MediaSources?.[0]; + + const playbackUri = + this.getProxyUrl() + + (mediasSource?.TranscodingUrl || + `/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${this.settings.apiKey}&Tag=${mediasSource?.ETag}`) + + `&reiverr_token=${this.token}`; + + const audioStreams: Stream['audioStreams'] = + mediasSource?.MediaStreams.filter((s) => s.Type === 'Audio').map((s) => ({ + bitrate: s.BitRate, + label: s.Language, + codec: s.Codec, + index: s.Index, + })) ?? []; + + const qualities: Stream['qualities'] = [ + ...bitrateQualities, + { + bitrate: mediasSource.Bitrate, + label: 'Original', + codec: undefined, + original: true, + }, + ].map((q, i) => ({ + ...q, + index: i, + })); + + const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate); + + const subtitles: Subtitles[] = mediasSource.MediaStreams.filter( + (s) => s.Type === 'Subtitle' && s.DeliveryUrl, + ).map((s, i) => ({ + src: this.getProxyUrl() + `${s.DeliveryUrl}&reiverr_token=${this.token}`, + lang: s.Language, + kind: 'subtitles', + label: s.DisplayTitle, + })); + + return { + streamId: '0', + title: movie.Name, + properties: [ + { + label: 'Video', + value: mediasSource.Bitrate || 0, + formatted: + mediasSource.MediaStreams.find((s) => s.Type === 'Video') + ?.DisplayTitle || 'Unknown', + }, + { + label: 'Size', + value: mediasSource.Size, + formatted: formatSize(mediasSource.Size), + }, + { + label: 'Filename', + value: mediasSource.Name, + formatted: undefined, + }, + { + label: 'Runtime', + value: mediasSource.RunTimeTicks, + formatted: formatTicksToTime(mediasSource.RunTimeTicks), + }, + ], + audioStreamIndex: + audioStreamIndex ?? + mediasSource?.DefaultAudioStreamIndex ?? + audioStreams[0].index, + audioStreams, + duration: mediasSource.RunTimeTicks + ? mediasSource.RunTimeTicks / 10_000_000 + : 0, + progress: progress, + qualities, + qualityIndex: getClosestBitrate(qualities, bitrate).index, + subtitles, + src: playbackUri, + // uri: + // proxyUrl + + // '/stream_new2/H4sIAAAAAAAAAw3OWXKDIAAA0Cvhggn9TBqSuJARBcU_CloiYp2Ojcvpm3eCB2EXASWjIAwRUkd4AF7XdYdQAY0kVPIjDTghrElZT0EJqGlv5I_64V5UOk58vOSO7F8bcjKYnvmusRg0zLe5Lv2YaWsSUpFMuTXOAAS5O66s_H5RBpbWrmftnV4JuIdZ8LNrf1laHs_FTqkMmro4z7CsSS7sRNpx2liFotJ5TPY45Q6tms3R45NSdYWGWZ6yvTm14.lXAV7r67IyOy85n5JHjQeFzV0z0guHo2YcrCzQQoEumgIZxrlQgQir2m4suLyPK22t6eX7nmG.Sn8SxRNdH7dBNKMxxGucvgyj8Lind4D.AeRg7d1BAQAA/master.m3u8' + + // `?reiverr_token=${userContext.token}`, + directPlay: + !!mediasSource?.SupportsDirectPlay || + !!mediasSource?.SupportsDirectStream, + }; + }; + + proxyHandler?: (options: { + req: any; + res: any; + uri: string; + targetUrl?: string; + }) => Promise = async (options) => { + const { req, res, uri, targetUrl } = options; + + const url = this.settings.baseUrl + uri; + + const headers = {}; + for (const key in req.headers) { + if (key === 'host') continue; + headers[key] = req.headers[key]; + } + + const proxyRes = await fetch(url, { + method: req.method || 'GET', + headers: { + ...headers, + Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${this.settings.apiKey}"`, + }, + }).catch((e) => { + console.error('error fetching proxy response', e); + res.status(500).send('Error fetching proxy response'); + }); + + if (!proxyRes) return; + + proxyRes.headers.forEach((value, name) => { + res.setHeader(name, value); + }); + + res.status(proxyRes.status); + Readable.from(proxyRes.body).pipe(res); + }; + + // Catalogue + + getOrderOptions?: () => Promise = async () => { + const directions: DirectionOption[] = [ + { + label: 'Ascending', + value: 'asc', + }, + { + label: 'Descending', + value: 'desc', + }, + ]; + + return [ + { + label: 'Title', + value: 'title', + directions, + }, + { + label: 'Date Added', + value: 'date-added', + directions, + }, + { + label: 'Date Created', + value: 'date-created', + directions, + }, + ]; + }; + + getCatalogue?: (options: { + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise>; + + getMovieCatalogue?: (options: { + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise> = async (options) => { + const { pagination, order, direction } = options; + + const sortBy: ItemSortBy[] = []; + + if (order === 'title') { + sortBy.push(ItemSortBy.Name); + } else if (order === 'date-added') { + sortBy.push(ItemSortBy.DateLastContentAdded); + } else if (order === 'date-created') { + sortBy.push(ItemSortBy.DateCreated); + } + // const items = ( + // await getLibraryItems(new PluginContext(context.settings, context.token)) + // ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie'); + const data = await this.api.items + .getItems({ + userId: this.settings.userId, + hasTmdbId: true, + recursive: true, + includeItemTypes: [BaseItemKind.Movie], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + sortBy, + sortOrder: [ + direction === 'asc' ? SortOrder.Ascending : SortOrder.Descending, + ], + startIndex: (pagination.page - 1) * pagination.itemsPerPage, + limit: pagination.itemsPerPage, + }) + .then((res) => res.data); + + return { + total: data.TotalRecordCount ?? data.Items?.length ?? 0, + page: pagination.page, + itemsPerPage: pagination.itemsPerPage, + items: + data?.Items?.map((item) => ({ + id: item.ProviderIds?.Tmdb, + tmdbId: item.ProviderIds?.Tmdb, + mediaType: 'movie' as const, + })) ?? [], + }; + }; + + getSeriesCatalogue?: (options: { + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise> = async (options) => { + const { pagination, order, direction } = options; + + const sortBy: ItemSortBy[] = []; + + if (order === 'title') { + sortBy.push(ItemSortBy.Name); + } else if (order === 'date-added') { + sortBy.push(ItemSortBy.DateLastContentAdded); + } else if (order === 'date-created') { + sortBy.push(ItemSortBy.DateCreated); + } + + const data = await this.api.items + .getItems({ + userId: this.settings.userId, + hasTmdbId: true, + recursive: true, + includeItemTypes: [BaseItemKind.Series], + fields: [ + ItemFields.ProviderIds, + ItemFields.Genres, + ItemFields.DateLastMediaAdded, + ItemFields.DateCreated, + ItemFields.MediaSources, + ], + sortBy, + sortOrder: [ + direction === 'asc' ? SortOrder.Ascending : SortOrder.Descending, + ], + startIndex: (pagination.page - 1) * pagination.itemsPerPage, + limit: pagination.itemsPerPage, + }) + .then((res) => res.data); + + return { + total: data.TotalRecordCount ?? data.Items?.length ?? 0, + page: pagination.page, + itemsPerPage: pagination.itemsPerPage, + items: + data?.Items?.map((item) => ({ + id: item.ProviderIds?.Tmdb, + tmdbId: item.ProviderIds?.Tmdb, + mediaType: 'series' as const, + })) ?? [], + }; + }; + + getMissingInCatalogue?: (options: { + pagination: PaginationParams; + order?: string; + direction?: string; + myListItems: Record; + }) => Promise>; +} diff --git a/backend/packages/jellyfin.plugin/src/plugin-context.ts b/backend/packages/jellyfin.plugin/src/plugin-context.ts deleted file mode 100644 index 84cb7fa..0000000 --- a/backend/packages/jellyfin.plugin/src/plugin-context.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - SourceProviderSettings, - UserContext, -} from '@aleksilassila/reiverr-plugin'; -import { Api as JellyfinApi } from './jellyfin.openapi'; -import { JELLYFIN_DEVICE_ID } from './utils'; - -export interface JellyfinSettings extends SourceProviderSettings { - apiKey: string; - baseUrl: string; - userId: string; -} - -export interface JellyfinUserContext extends UserContext { - settings: JellyfinSettings; -} - -export class PluginContext { - api: JellyfinApi; - settings: JellyfinSettings; - token: string; - - constructor(settings: SourceProviderSettings, token = '') { - this.token = token; - this.settings = settings as JellyfinSettings; - this.api = new JellyfinApi({ - baseURL: settings.baseUrl, - headers: { - Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`, - }, - paramsSerializer: { - indexes: null, - }, - }); - } -} diff --git a/backend/packages/jellyfin.plugin/src/settings.ts b/backend/packages/jellyfin.plugin/src/settings.ts deleted file mode 100644 index bad9afd..0000000 --- a/backend/packages/jellyfin.plugin/src/settings.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - SettingsManager, - SourceProviderSettingsTemplate, - ValidationResponse, -} from '@aleksilassila/reiverr-plugin'; -import { PluginContext } from './plugin-context'; - -export class JellyfinSettingsManager extends SettingsManager { - validateSettings = async ( - settings: Record, - ): Promise => { - let isValid = true; - const errors = { - baseUrl: '', - apiKey: '', - userId: '', - }; - const replace: Record = {}; - - if (!settings.baseUrl) { - isValid = false; - errors.baseUrl = 'Base URL is required'; - } - - if (!settings.apiKey) { - isValid = false; - errors.apiKey = 'API Key is required'; - } - - if (!settings.userId) { - isValid = false; - errors.userId = 'User ID is required'; - } - - if (isValid) { - const context = new PluginContext(settings as any); - let [user, err] = await context.api.users - .getUserById(settings.userId, { timeout: 5000 }) - .then((res) => [res.data, undefined]) - .catch((err) => [undefined, err.message]); - - if (!user && err) { - [user, err] = await context.api.users - .getUsers() - .then((res) => res.data?.find((u) => u.Name === settings.userId)) - .then((user) => { - if (!user || !user.Id) { - return [undefined, 'User not found']; - } - - replace.userId = user.Id; - - return [user, undefined]; - }) - .catch((err) => [undefined, err.message]); - } - - if (!user && err) { - isValid = false; - errors.userId = `Could not get user: ${err}`; - } - } - - return { - isValid, - errors, - settings: { ...settings, ...replace }, - }; - }; - - getSettingsTemplate: () => SourceProviderSettingsTemplate = () => ({ - baseUrl: { - type: 'string', - label: 'Base URL', - placeholder: 'http://localhost:8096', - required: true, - }, - apiKey: { - type: 'password', - label: 'API Key', - placeholder: '', - required: true, - }, - userId: { - type: 'string', - label: 'Username or User ID', - placeholder: 'username or user id', - required: true, - }, - }); -} diff --git a/backend/packages/reiverr-plugin/package.json b/backend/packages/reiverr-plugin/package.json index be8c127..3f9be68 100644 --- a/backend/packages/reiverr-plugin/package.json +++ b/backend/packages/reiverr-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@aleksilassila/reiverr-plugin", - "version": "3.0.0", + "version": "4.0.0", "main": "dist/src/index", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/backend/packages/reiverr-plugin/src/reiverr-plugin.ts b/backend/packages/reiverr-plugin/src/reiverr-plugin.ts index 62b8a76..04434ab 100644 --- a/backend/packages/reiverr-plugin/src/reiverr-plugin.ts +++ b/backend/packages/reiverr-plugin/src/reiverr-plugin.ts @@ -1,48 +1,144 @@ +import * as packageJson from '../package.json'; import { - EpisodeMetadata, CatalogueItem, - MovieMetadata, + OrderOption, PaginatedResponse, PaginationParams, PlaybackConfig, + SourceProviderSettings, SourceProviderSettingsTemplate, - UserContext, - ValidationResponse, Stream, StreamCandidate, - DirectionOption, - OrderOption, + UserContext, + ValidationResponse, } from './types'; -import * as packageJson from '../package.json'; /** - * PluginProvider is a class that provides a list of SourceProvider instances. - * This is so that you can provide multiple SourceProviders in a single plugin. + * ReiverrPlugin is a class that a plugin should default export (or an array of ReiverrPlugins). It contains "static" methods that can be called without Reiverr user context. * - * The plugin should default export a class that extends PluginProvider. - * - * @see SourceProvider + * @see MediaSourceProvider */ -export abstract class PluginProvider { - /** - * @returns {SourceProvider[]} A list of SourceProvider instances that the plugin provides. - */ - abstract getPlugins(): SourceProvider[]; -} +export abstract class ReiverrPlugin { + abstract name: string; -export class SettingsManager { + /** + * This method is called for every user request, and it should return an object that can handle requests that depend on an user that has connected to the plugin / configured it as a source in their settings page. + */ + abstract getMediaSourceProvider: ( + userContext: UserContext, + ) => MediaSourceProvider; + + /** + * @returns The settings that the plugin supports. @see SourceProviderSettingsTemplate + */ getSettingsTemplate: () => SourceProviderSettingsTemplate = () => ({}); - validateSettings: ( - settings: Record, - ) => Promise = async () => ({ + validateSettings: (options: { + settings: Record; + }) => Promise = async () => ({ isValid: true, errors: {}, settings: {}, }); + + getPluginVersion(): string { + return packageJson.version; + } + + _isCompatibleWith(version: string): boolean { + const pluginVersion = this.getPluginVersion(); + const pluginVersionParts = pluginVersion.split('.'); + const versionParts = version.split('.'); + + if ( + !pluginVersionParts.length || + pluginVersionParts.length !== versionParts.length + ) { + return false; + } + + return ( + pluginVersionParts[0] === versionParts[0] && + Number(pluginVersionParts[1]) >= Number(versionParts[1]) + ); + } } -export class CatalogueProvider { +/** + * MediaSourceProvider is a class that handles all requests for Reiverr users that have configured the plugin as MediaSource. A new MediaSourceProvider is instantiated for each request / function call, and it contains data about the Reiverr user that called the function. + */ +export abstract class MediaSourceProvider { + /** + * An id unique to each Reiverr user + */ + protected userId: string; + + /** + * The access token of the user that can be used to authenticate requests to the backend + * (e.g. proxy requests) + */ + protected token: string; + /** + * The id of the MediaSource instance that the user is using to access the SourceProvider + */ + protected sourceId: string; + + /** + * @see SourceProviderSettings + */ + protected settings: SourceProviderSettings; + + constructor(userContext: UserContext) { + this.userId = userContext.userId; + this.token = userContext.token; + this.sourceId = userContext.sourceId; + this.settings = userContext.settings; + } + + /** + * Returns a list of stream candidates for a movie that the user can choose to stream from. + * + * @see StreamCandidate + */ + abstract getTmdbMovieCandidates?: (options: { + tmdbMovie: any; + }) => Promise<{ candidates: StreamCandidate[] }>; + + /** + * Returns a list of stream candidates for an episode that the user can choose to stream from. + * + * @see StreamCandidate + */ + abstract getTmdbEpisodeCandidates?: (options: { + tmdbSeries: any; + tmdbEpisode: any; + }) => Promise<{ candidates: StreamCandidate[] }>; + + /** + * Returns a specific stream for a movie that the user can stream from. + * + * @see Stream + */ + abstract getStream?: (options: { + streamId: string; + config?: PlaybackConfig; + }) => Promise; + + /** + * This method will be called when the client makes a request to the provider's + * proxy endpoint (e.g. /api/proxy/:providerName/:path). This can be used to + * relay video streams and subtitles to the client, by making a request to an + * external service and then returning the response to the client. Ideally, + * the stream url pointed to by a `Stream` object should use the proxy endpoint + * so that the plugin can handle the video requests here. + */ + abstract proxyHandler?: (options: { + req: any; + res: any; + uri: string; + targetUrl?: string; + }) => Promise; + getOrderOptions?: () => Promise = () => Promise.resolve([ { @@ -61,14 +157,10 @@ export class CatalogueProvider { }, ]); - getSupportsSortDirection?: () => Promise = () => - Promise.resolve(false); - /** * Returns an index of all items available in the source. */ - getCatalogue?: (options: { - context: UserContext; + abstract getCatalogue?: (options: { pagination: PaginationParams; order?: string; direction?: string; @@ -77,8 +169,7 @@ export class CatalogueProvider { /** * Returns an index of all movies available in the source. */ - getMovieCatalogue?: (options: { - context: UserContext; + abstract getMovieCatalogue?: (options: { pagination: PaginationParams; order?: string; direction?: string; @@ -87,8 +178,7 @@ export class CatalogueProvider { /** * Returns an index of all series available in the source. */ - getSeriesCatalogue?: (options: { - context: UserContext; + abstract getSeriesCatalogue?: (options: { pagination: PaginationParams; order?: string; direction?: string; @@ -97,8 +187,7 @@ export class CatalogueProvider { /** * Filters my list items to only include those that are not available in the source. */ - getMissingInCatalogue?: (options: { - context: UserContext; + abstract getMissingInCatalogue?: (options: { pagination: PaginationParams; order?: string; direction?: string; @@ -106,116 +195,6 @@ export class CatalogueProvider { }) => Promise>; } -/** - * SourceProvider is a class that provides a set of methods to interact with a streaming source. - * - * Important distinction between SourceProvider and MediaSource: - * An user doesn't directly add a SourceProvider to their account, but instead users can configure - * `MediaSources`. MediaSource is essentially all the user-specific configuration that SourceProvider - * needs to function. This way different users can have different configurations for the same - * SourceProvider - for example, two users can use the same JellyfinPlugin (JellyfinSourceProvider) - * to access two different Jellyfin servers, because they access the provider with their own - * MediaSource instances. - * - * UserContext is used to pass the user-specific configuration to the SourceProvider methods. - * - * @see UserContext - * @see PluginProvider - */ -export abstract class SourceProvider { - abstract name: string; - - settingsManager: SettingsManager = new SettingsManager(); - - catalogueProvider: CatalogueProvider | undefined; - - /** - * Returns a list of stream candidates for a movie that the user can choose to stream from. - * - * @see StreamCandidate - */ - getMovieStreams?: ( - tmdbId: string, - metadata: MovieMetadata, - context: UserContext, - config?: PlaybackConfig, - ) => Promise<{ candidates: StreamCandidate[] }>; - - /** - * Returns a list of stream candidates for an episode that the user can choose to stream from. - * - * @see StreamCandidate - */ - getEpisodeStreams?: ( - tmdbId: string, - metadata: EpisodeMetadata, - context: UserContext, - config?: PlaybackConfig, - ) => Promise<{ candidates: StreamCandidate[] }>; - - /** - * Returns a specific stream for a movie that the user can stream from. - * - * @see Stream - */ - getMovieStream?: ( - tmdbId: string, - metadata: MovieMetadata, - key: string, - context: UserContext, - config?: PlaybackConfig, - ) => Promise; - - /** - * Returns a specific stream for an episode that the user can stream from. - * - * @see Stream - */ - getEpisodeStream?: ( - tmdbId: string, - metadata: EpisodeMetadata, - key: string, - context: UserContext, - config?: PlaybackConfig, - ) => Promise; - - /** - * This method will be called when the client makes a request to the provider's - * proxy endpoint (e.g. /api/proxy/:providerName/:path). This can be used to - * relay video streams and subtitles to the client, by making a request to an - * external service and then returning the response to the client. Ideally, - * the stream url pointed to by a `Stream` object should use the proxy endpoint - * so that the plugin can handle the video requests here. - */ - proxyHandler?: ( - req: any, - res: any, - options: { context: UserContext; uri: string; targetUrl?: string }, - ) => Promise; - - _getPluginVersion(): string { - return getReiverrPluginVersion(); - } - - _isCompatibleWith(version: string): boolean { - const pluginVersion = getReiverrPluginVersion(); - const pluginVersionParts = pluginVersion.split('.'); - const versionParts = version.split('.'); - - if ( - !pluginVersionParts.length || - pluginVersionParts.length !== versionParts.length - ) { - return false; - } - - return ( - pluginVersionParts[0] === versionParts[0] && - Number(pluginVersionParts[1]) >= Number(versionParts[1]) - ); - } -} - export function getReiverrPluginVersion(): string { return packageJson.version; } diff --git a/backend/packages/reiverr-plugin/src/types.ts b/backend/packages/reiverr-plugin/src/types.ts index 5e254ca..44d1429 100644 --- a/backend/packages/reiverr-plugin/src/types.ts +++ b/backend/packages/reiverr-plugin/src/types.ts @@ -113,7 +113,7 @@ export type StreamCandidate = { /** * Unique id for the stream, that can be used to later stream the specific stream. */ - key: string; + streamId: string; /** * Title of the stream, presented to the user. diff --git a/backend/packages/torrent-stream.plugin/src/index.ts b/backend/packages/torrent-stream.plugin/src/index.ts index dfc94b0..1938017 100644 --- a/backend/packages/torrent-stream.plugin/src/index.ts +++ b/backend/packages/torrent-stream.plugin/src/index.ts @@ -1,353 +1,72 @@ import { - EpisodeMetadata, - MovieMetadata, - PlaybackConfig, - PluginProvider, - SettingsManager, - SourceProvider, - Stream, - StreamCandidate, - Subtitles, + MediaSourceProvider, + ReiverrPlugin, + SourceProviderSettingsTemplate, UserContext, + ValidationResponse, } from '@aleksilassila/reiverr-plugin'; -import { - getEpisodeTorrents, - getMovieTorrents, - getStreamCandidates, -} from './lib/jackett.api'; -import { getFiles } from './lib/torrent-manager'; -import { TorrentSettingsManager } from './settings'; -import type { TorrentSettings } from './types'; -import { - getContentType, - srt2webvtt, - subtitleExtensions, - videoExtensions, -} from './utils'; +import { testConnection } from './lib/jackett.api'; +import { TorrentMediaSourceProvider } from './media-source-provider'; -export default class TorrentPluginsProvider extends PluginProvider { - getPlugins(): SourceProvider[] { - return [new TorrentProvider()]; - } -} - -class TorrentProvider extends SourceProvider { +class TorrentPlugin extends ReiverrPlugin { name: string = 'torrent'; - settingsManager: SettingsManager = new TorrentSettingsManager(); - getProxyUrl(sourceId: string) { - return `/api/sources/${sourceId}/proxy`; - } + getMediaSourceProvider: (userContext: UserContext) => MediaSourceProvider = ( + context, + ) => new TorrentMediaSourceProvider(context); - getMovieStreams = async ( - tmdbId: string, - metadata: MovieMetadata, - context: UserContext, - config?: PlaybackConfig, - ): Promise<{ candidates: StreamCandidate[] }> => { - const settings = context.settings as TorrentSettings; - - if (!metadata.title || !metadata.year) return { candidates: [] }; - const torrents = await getMovieTorrents( - settings, - metadata.title, - metadata.year, - ).items; - - const candidates = getStreamCandidates(torrents, { - runtime: metadata.runtime, - }); - - return { candidates }; - }; - - getEpisodeStreams = async ( - tmdbId: string, - metadata: EpisodeMetadata, - context: UserContext, - config?: PlaybackConfig, - ): Promise<{ candidates: StreamCandidate[] }> => { - const settings = context.settings as TorrentSettings; - - const torrents = getEpisodeTorrents( - settings, - metadata.series, - metadata.season, - metadata.episode, - ); - const items = await torrents.items; - const seasonPacks = await torrents.seasonPacks; - - const candidates = [ - ...getStreamCandidates(items, { - runtime: metadata.episodeRuntime, - }), - ...getStreamCandidates(seasonPacks, { - runtime: metadata.episodeRuntime, - files: metadata.seasonEpisodes, - }), - ]; - - candidates.sort((a, b) => { - const aSeeders = - Number(a.properties.find((p) => p.label === 'Seeders')?.value) || 0; - const bSeeders = - Number(b.properties.find((p) => p.label === 'Seeders')?.value) || 0; - const aPeers = - Number(a.properties.find((p) => p.label === 'Peers')?.value) || 0; - const bPeers = - Number(b.properties.find((p) => p.label === 'Peers')?.value) || 0; - - if (aSeeders + aPeers > bSeeders + bPeers) return -1; - if (aSeeders + aPeers < bSeeders + bPeers) return 1; - - return 0; - }); - - return { candidates }; - }; - - getMovieStream = async ( - tmdbId: string, - metadata: MovieMetadata, - key: string, - context: UserContext, - config: PlaybackConfig = { - audioStreamIndex: undefined, - bitrate: undefined, - progress: undefined, - defaultLanguage: undefined, - deviceProfile: undefined, + getSettingsTemplate: () => SourceProviderSettingsTemplate = () => ({ + baseUrl: { + type: 'string', + label: 'Jackett URL', + placeholder: + 'http://127.0.0.1:9117/api/v2.0/indexers/indexer/results/torznab/', + required: true, }, - ): Promise => { - const settings = context.settings as TorrentSettings; - - if (!metadata.title || !metadata.year) { - throw new Error('Metadata not found'); - } - - const torrent = await getMovieTorrents( - settings, - metadata.title, - metadata.year, - ).get(key); - - if (!torrent) { - throw new Error('Torrent not found'); - } - - const src = `${this.getProxyUrl( - context.sourceId, - )}/magnet?link=${encodeURIComponent(torrent?.link)}&reiverr_token=${ - context.token - }`; - - const files = await getFiles(context.userId, torrent.link); - - files.forEach((f) => console.log(`file: ${f.name}`)); - - const subtitles: Subtitles[] = files - .filter((f) => subtitleExtensions.some((ext) => f.name.endsWith(ext))) - .map((f) => ({ - kind: 'subtitles', - src: `${this.getProxyUrl( - context.sourceId, - )}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${ - context.token - }&file=${f.name}`, - label: f.name, - lang: 'unknown', - })); - - return { - src, - audioStreamIndex: 0, - audioStreams: [], - duration: 0, - key: '0', - properties: [], - progress: config.progress || 0, - qualities: [], - qualityIndex: 0, - subtitles, - title: torrent.title ?? 'Test', - directPlay: true, - }; - }; - - getEpisodeStream = async ( - tmdbId: string, - metadata: EpisodeMetadata, - key: string, - context: UserContext, - config: PlaybackConfig = { - audioStreamIndex: undefined, - bitrate: undefined, - progress: undefined, - defaultLanguage: undefined, - deviceProfile: undefined, + apiKey: { + type: 'password', + label: 'Jackett API Key', + placeholder: '', + required: true, }, - ): Promise => { - const settings = context.settings as TorrentSettings; + }); - const torrent = await getEpisodeTorrents( - settings, - metadata.series, - metadata.season, - metadata.episode, - ).get(key); - - if (!torrent) { - throw new Error('Torrent not found'); - } - - const src = `${this.getProxyUrl( - context.sourceId, - )}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${ - context.token - }&season=${metadata.season}&episode=${metadata.episode}`; - - const files = await getFiles(context.userId, torrent.link); - - const subtitles: Subtitles[] = files - .filter((f) => subtitleExtensions.some((ext) => f.name.endsWith(ext))) - .map((f) => ({ - kind: 'subtitles', - src: `${this.getProxyUrl( - context.sourceId, - )}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${ - context.token - }&file=${f.name}`, - label: f.name, - lang: 'unknown', - })); - - return { - src, - audioStreamIndex: 0, - audioStreams: [], - duration: 0, - key: '0', - properties: [], - progress: config.progress || 0, - qualities: [], - qualityIndex: 0, - subtitles, - title: torrent.title ?? 'Test', - directPlay: true, + validateSettings: (options: { + settings: Record; + }) => Promise = async ({ settings }) => { + const { baseUrl, apiKey } = settings; + let isValid = true; + const errors = { + baseUrl: '', + apiKey: '', }; - }; - proxyHandler = async ( - req: any, - res: any, - options: { context: UserContext; uri: string; targetUrl?: string }, - ): Promise => { - const { uri, context } = options; - const settings = context.settings as TorrentSettings; - - const params = new URLSearchParams(uri.split('?').slice(1).join('?')); - const magnetLink = params.get('link'); - const fileName = params.get('file'); - const season = params.get('season'); - const episode = params.get('episode'); - - console.log('magnetLink', magnetLink); - - if (!magnetLink) { - res.status(400).send('No magnet link provided'); - return; + if (!baseUrl) { + isValid = false; + errors.baseUrl = 'Base URL is required'; } - const files = await getFiles(context.userId, magnetLink); - - let file: TorrentStream.TorrentFile | undefined; - - if (fileName) { - file = files.find((f) => f.name === fileName); - } else { - const videoFiles = files.filter((f) => - videoExtensions.some((ext) => f.name.endsWith(ext)), - ); - file = - videoFiles.length > 1 && season && episode - ? videoFiles.find((f) => { - const name = f.name.toUpperCase(); - return ( - name.includes( - `S${season.toString().padStart(2, '0')}E${episode - .toString() - .padStart(2, '0')}`, - ) || - name.includes(`S${season.toString()}E${episode.toString()}`) || - name.includes( - `${season.toString().padStart(2, '0')}X${episode - .toString() - .padStart(2, '0')}`, - ) || - name.includes(`${season.toString()}X${episode.toString()}`) - ); - }) || videoFiles[0] - : videoFiles[0]; + if (!apiKey) { + isValid = false; + errors.apiKey = 'API Key is required'; } - if (file) { - const extension = file.name.split('.').pop(); - const contentType = extension ? getContentType(extension) : undefined; - console.log( - 'serving file', - file.name, - 'with content type', - contentType, - file.length, - ); - - const range = req.headers.range; - if (range) { - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : file.length - 1; - const chunksize = end - start + 1; - res.writeHead(206, { - 'Content-Range': `bytes ${start}-${end}/${file.length}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': chunksize, - ...(contentType ? { 'Content-Type': contentType } : {}), + if (isValid) { + await testConnection({ baseUrl, apiKey }) + .then((err) => { + if (err) { + isValid = false; + errors.apiKey = err; + } + }) + .catch((e) => { + isValid = false; + errors.baseUrl = e.message ?? 'Invalid URL'; }); - file.createReadStream({ start, end }).pipe(res); - } else if (extension === 'srt') { - res.setHeader('Content-Type', 'text/vtt'); - - const srt = await new Promise(async (resolve, reject) => { - const stream = await file.createReadStream(); - let body = ''; - stream.on('data', (chunk: string) => { - body += chunk; - }); - stream.on('end', () => { - resolve(body); - }); - stream.on('error', (err: any) => { - reject(err); - }); - }); - - res.send(srt2webvtt(srt)); - } else { - res.setHeader('Accept-Ranges', 'bytes'); - if (contentType) { - res.setHeader('Content-Type', contentType); - } - res.setHeader('Content-Length', file.length); - file.createReadStream().pipe(res); - } - - // res.setHeader('Accept-Ranges', 'bytes'); - // res.setHeader('Content-Type', 'video/' + extension); - // res.setHeader('Content-Length', file.length); - // file.createReadStream().pipe(res); - } else { - res.status(404).send('No file found'); } + + return { isValid, errors, settings }; }; } + +export default new TorrentPlugin(); diff --git a/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts b/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts index 9d150f9..e288a7b 100644 --- a/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts +++ b/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts @@ -2,7 +2,7 @@ import axios, { AxiosError } from 'axios'; import { XMLParser } from 'fast-xml-parser'; import { StreamCandidate } from '@aleksilassila/reiverr-plugin'; import { TorrentSettings } from '../types'; -import { formatSize, formatBitrate } from '../utils'; +import { formatSize, formatBitrate, EPISODE_SEPARATOR } from '../utils'; export type JackettItem = { title: string; @@ -132,10 +132,15 @@ export const getEpisodeTorrents = ( export function getStreamCandidates( torrents: JackettItem[], - options: { runtime?: number; files?: number } = {}, + options: { + runtime?: number; + files?: number; + season?: number; + episode?: number; + } = {}, ): StreamCandidate[] { return torrents.map((torrent) => { - const { runtime = 0, files = 1 } = options; + const { runtime = 0, files = 1, season, episode } = options; const seeders = Number(getTorrentAttribute(torrent, 'seeders')) || 0; const peers = Number(getTorrentAttribute(torrent, 'peers')) || 0; @@ -148,10 +153,11 @@ export function getStreamCandidates( const bitrate = runtime > 0 && files > 0 ? sizePerFile / runtime : 0; return { - key: - getTorrentAttribute(torrent, 'infohash') || - torrent.title || - torrent.guid, + streamId: + getTorrentAttribute(torrent, 'infohash') + + (season !== undefined && episode !== undefined + ? `${EPISODE_SEPARATOR}${season}${EPISODE_SEPARATOR}${episode}` + : ''), title: torrent.title || torrent.description, properties: [ { diff --git a/backend/packages/torrent-stream.plugin/src/media-source-provider.ts b/backend/packages/torrent-stream.plugin/src/media-source-provider.ts new file mode 100644 index 0000000..0f97782 --- /dev/null +++ b/backend/packages/torrent-stream.plugin/src/media-source-provider.ts @@ -0,0 +1,315 @@ +import { + CatalogueItem, + MediaSourceProvider, + PaginatedResponse, + PaginationParams, + PlaybackConfig, + Stream, + StreamCandidate, + Subtitles, + UserContext, +} from '@aleksilassila/reiverr-plugin'; +import { + getEpisodeTorrents, + getMovieTorrents, + getStreamCandidates, +} from './lib/jackett.api'; +import { getFiles } from './lib/torrent-manager'; +import type { TorrentSettings } from './types'; +import { + EPISODE_SEPARATOR, + getContentType, + srt2webvtt, + subtitleExtensions, + videoExtensions, +} from './utils'; + +export class TorrentMediaSourceProvider extends MediaSourceProvider { + private proxyUrl: string; + + constructor(context: UserContext) { + super(context); + this.proxyUrl = `/api/sources/${this.sourceId}/proxy`; + } + + getTmdbMovieCandidates?: + | ((options: { + tmdbMovie: any; + }) => Promise<{ candidates: StreamCandidate[] }>) + | undefined = async ({ tmdbMovie }) => { + const settings = this.settings as TorrentSettings; + + const year = tmdbMovie.release_date + ? new Date(tmdbMovie.release_date).getFullYear() + : undefined; + + if (!tmdbMovie.title || !year) return { candidates: [] }; + const torrents = await getMovieTorrents(settings, tmdbMovie.title, year) + .items; + + const candidates = getStreamCandidates(torrents, { + runtime: tmdbMovie.runtime, + }); + + return { candidates }; + }; + + getTmdbEpisodeCandidates?: + | ((options: { + tmdbSeries: any; + tmdbEpisode: any; + }) => Promise<{ candidates: StreamCandidate[] }>) + | undefined = async ({ tmdbSeries, tmdbEpisode }) => { + const settings = this.settings as TorrentSettings; + + const torrents = getEpisodeTorrents( + settings, + tmdbSeries.name, + tmdbEpisode.season_number, + tmdbEpisode.episode_number, + ); + const items = await torrents.items; + const seasonPacks = await torrents.seasonPacks; + + const seasonEpisodes = + tmdbSeries?.seasons?.find( + (s: any) => s.season_number === tmdbEpisode.season_number, + )?.episode_count ?? 1; + const candidates = [ + ...getStreamCandidates(items, { + runtime: tmdbEpisode.runtime, + season: tmdbEpisode.season_number, + episode: tmdbEpisode.episode_number, + }), + ...getStreamCandidates(seasonPacks, { + runtime: tmdbEpisode.runtime, + files: seasonEpisodes, + season: tmdbEpisode.season_number, + episode: tmdbEpisode.episode_number, + }), + ]; + + candidates.sort((a, b) => { + const aSeeders = + Number(a.properties.find((p) => p.label === 'Seeders')?.value) || 0; + const bSeeders = + Number(b.properties.find((p) => p.label === 'Seeders')?.value) || 0; + const aPeers = + Number(a.properties.find((p) => p.label === 'Peers')?.value) || 0; + const bPeers = + Number(b.properties.find((p) => p.label === 'Peers')?.value) || 0; + + if (aSeeders + aPeers > bSeeders + bPeers) return -1; + if (aSeeders + aPeers < bSeeders + bPeers) return 1; + + return 0; + }); + + return { candidates }; + }; + + getStream?: + | ((options: { + streamId: string; + config?: PlaybackConfig; + }) => Promise) + | undefined = async ({ streamId, config }) => { + const settings = this.settings as TorrentSettings; + const [link, season, episode] = streamId.split(EPISODE_SEPARATOR); + + // const torrent = await getEpisodeTorrents( + // settings, + // metadata.series, + // metadata.season, + // metadata.episode, + // ).get(key); + + if (!link) { + throw new Error('Torrent not found'); + } + + let src = `${this.proxyUrl}/magnet?link=${encodeURIComponent(link)}&reiverr_token=${ + this.token + }`; + + if (season && episode) { + src += `&season=${season}&episode=${episode}`; + } + + const files = await getFiles(this.userId, link); + + const subtitles: Subtitles[] = files + .filter((f) => subtitleExtensions.some((ext) => f.name.endsWith(ext))) + .map((f) => ({ + kind: 'subtitles', + src: `${this.proxyUrl}/magnet?link=${encodeURIComponent(link)}&reiverr_token=${ + this.token + }&file=${f.name}`, + label: f.name, + lang: 'unknown', + })); + + return { + streamId, + src, + audioStreamIndex: 0, + audioStreams: [], + duration: 0, + properties: [], + progress: config?.progress || 0, + qualities: [], + qualityIndex: 0, + subtitles, + title: 'Unknown', + directPlay: true, + }; + }; + + proxyHandler?: + | ((options: { + req: any; + res: any; + uri: string; + targetUrl?: string; + }) => Promise) + | undefined = async ({ req, res, uri, targetUrl }) => { + const settings = this.settings as TorrentSettings; + + const params = new URLSearchParams(uri.split('?').slice(1).join('?')); + const magnetLink = params.get('link'); + const fileName = params.get('file'); + const season = params.get('season'); + const episode = params.get('episode'); + + console.log('magnetLink', magnetLink); + + if (!magnetLink) { + res.status(400).send('No magnet link provided'); + return; + } + + const files = await getFiles(this.userId, magnetLink); + + let file: TorrentStream.TorrentFile | undefined; + + if (fileName) { + file = files.find((f) => f.name === fileName); + } else { + const videoFiles = files.filter((f) => + videoExtensions.some((ext) => f.name.endsWith(ext)), + ); + file = + videoFiles.length > 1 && season && episode + ? videoFiles.find((f) => { + const name = f.name.toUpperCase(); + return ( + name.includes( + `S${season.toString().padStart(2, '0')}E${episode + .toString() + .padStart(2, '0')}`, + ) || + name.includes(`S${season.toString()}E${episode.toString()}`) || + name.includes( + `${season.toString().padStart(2, '0')}X${episode + .toString() + .padStart(2, '0')}`, + ) || + name.includes(`${season.toString()}X${episode.toString()}`) + ); + }) || videoFiles[0] + : videoFiles[0]; + } + + if (file) { + const extension = file.name.split('.').pop(); + const contentType = extension ? getContentType(extension) : undefined; + console.log( + 'serving file', + file.name, + 'with content type', + contentType, + file.length, + ); + + const range = req.headers.range; + if (range) { + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : file.length - 1; + const chunksize = end - start + 1; + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${file.length}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + ...(contentType ? { 'Content-Type': contentType } : {}), + }); + file.createReadStream({ start, end }).pipe(res); + } else if (extension === 'srt') { + res.setHeader('Content-Type', 'text/vtt'); + + const srt = await new Promise(async (resolve, reject) => { + const stream = await file.createReadStream(); + let body = ''; + stream.on('data', (chunk: string) => { + body += chunk; + }); + stream.on('end', () => { + resolve(body); + }); + stream.on('error', (err: any) => { + reject(err); + }); + }); + + res.send(srt2webvtt(srt)); + } else { + res.setHeader('Accept-Ranges', 'bytes'); + if (contentType) { + res.setHeader('Content-Type', contentType); + } + res.setHeader('Content-Length', file.length); + file.createReadStream().pipe(res); + } + + // res.setHeader('Accept-Ranges', 'bytes'); + // res.setHeader('Content-Type', 'video/' + extension); + // res.setHeader('Content-Length', file.length); + // file.createReadStream().pipe(res); + } else { + res.status(404).send('No file found'); + } + }; + + getCatalogue?: + | ((options: { + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise>) + | undefined; + + getMovieCatalogue?: + | ((options: { + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise>) + | undefined; + + getSeriesCatalogue?: + | ((options: { + pagination: PaginationParams; + order?: string; + direction?: string; + }) => Promise>) + | undefined; + + getMissingInCatalogue?: + | ((options: { + pagination: PaginationParams; + order?: string; + direction?: string; + myListItems: Record; + }) => Promise>) + | undefined; +} diff --git a/backend/packages/torrent-stream.plugin/src/settings.ts b/backend/packages/torrent-stream.plugin/src/settings.ts deleted file mode 100644 index 9f22e63..0000000 --- a/backend/packages/torrent-stream.plugin/src/settings.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - SettingsManager, - SourceProviderSettingsTemplate, - ValidationResponse, -} from '@aleksilassila/reiverr-plugin'; -import { testConnection } from './lib/jackett.api'; - -export class TorrentSettingsManager extends SettingsManager { - validateSettings = async ( - settings: Record, - ): Promise => { - const { baseUrl, apiKey } = settings; - let isValid = true; - const errors = { - baseUrl: '', - apiKey: '', - }; - - if (!baseUrl) { - isValid = false; - errors.baseUrl = 'Base URL is required'; - } - - if (!apiKey) { - isValid = false; - errors.apiKey = 'API Key is required'; - } - - if (isValid) { - await testConnection({ baseUrl, apiKey }) - .then((err) => { - if (err) { - isValid = false; - errors.apiKey = err; - } - }) - .catch((e) => { - isValid = false; - errors.baseUrl = e.message ?? 'Invalid URL'; - }); - } - - return { isValid, errors, settings }; - }; - - getSettingsTemplate = (): SourceProviderSettingsTemplate => ({ - baseUrl: { - type: 'string', - label: 'Jackett URL', - placeholder: - 'http://127.0.0.1:9117/api/v2.0/indexers/indexer/results/torznab/', - required: true, - }, - apiKey: { - type: 'password', - label: 'Jackett API Key', - placeholder: '', - required: true, - }, - }); -} diff --git a/backend/packages/torrent-stream.plugin/src/types.ts b/backend/packages/torrent-stream.plugin/src/types.ts index 4a43ac8..a8beae5 100644 --- a/backend/packages/torrent-stream.plugin/src/types.ts +++ b/backend/packages/torrent-stream.plugin/src/types.ts @@ -1,13 +1,6 @@ -import type { - SourceProviderSettings, - UserContext, -} from '@aleksilassila/reiverr-plugin'; +import type { SourceProviderSettings } from '@aleksilassila/reiverr-plugin'; export interface TorrentSettings extends SourceProviderSettings { apiKey: string; baseUrl: string; } - -export interface TorrentUserContext extends UserContext { - settings: TorrentSettings; -} diff --git a/backend/packages/torrent-stream.plugin/src/utils.ts b/backend/packages/torrent-stream.plugin/src/utils.ts index d361c99..d951575 100644 --- a/backend/packages/torrent-stream.plugin/src/utils.ts +++ b/backend/packages/torrent-stream.plugin/src/utils.ts @@ -1,3 +1,5 @@ +export const EPISODE_SEPARATOR = ':EPISODE:'; + export function formatSize(size: number) { const gbs = size / 1024 / 1024 / 1024; const mbs = size / 1024 / 1024; diff --git a/backend/src/metadata/metadata.entity.ts b/backend/src/metadata/metadata.entity.ts index dc18c03..67fefed 100644 --- a/backend/src/metadata/metadata.entity.ts +++ b/backend/src/metadata/metadata.entity.ts @@ -6,7 +6,11 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { TmdbMovieFull, TmdbSeriesFull } from './tmdb/tmdb.dto'; +import { + TmdbEpisodeFull, + TmdbMovieFull, + TmdbSeriesFull, +} from './tmdb/tmdb.dto'; import { TMDB_CACHE_TTL } from 'src/consts'; import { LibraryItem } from 'src/user-data/library/library.entity'; @@ -155,3 +159,11 @@ export class SeriesMetadata { return false; } } + +/** + * TODO + */ +export class EpisodeMetadata { + @ApiProperty({ required: false, type: 'string' }) + tmdbEpisode: TmdbEpisodeFull; +} diff --git a/backend/src/metadata/metadata.service.ts b/backend/src/metadata/metadata.service.ts index c0bdf03..8392ab9 100644 --- a/backend/src/metadata/metadata.service.ts +++ b/backend/src/metadata/metadata.service.ts @@ -1,8 +1,13 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { Repository } from 'typeorm'; -import { MovieMetadata, SeriesMetadata } from './metadata.entity'; +import { + EpisodeMetadata, + MovieMetadata, + SeriesMetadata, +} from './metadata.entity'; import { MOVIE_REPOSITORY, SERIES_REPOSITORY } from './metadata.providers'; import { TmdbService } from './tmdb/tmdb.service'; +import { TmdbEpisodeFull } from './tmdb/tmdb.dto'; @Injectable() export class MetadataService { @@ -105,4 +110,19 @@ export class MetadataService { return series; } + + /** + * TODO: Use episode entities? + */ + async getEpisodeByTmdbId(options: { + tmdbId: string; + season: number; + episode: number; + }): Promise { + const tmdbEpisode = await this.tmdbService.getFullEpisode(options); + + return { + tmdbEpisode, + }; + } } diff --git a/backend/src/metadata/tmdb/tmdb.controller.ts b/backend/src/metadata/tmdb/tmdb.controller.ts index b1ed1f8..4009f02 100644 --- a/backend/src/metadata/tmdb/tmdb.controller.ts +++ b/backend/src/metadata/tmdb/tmdb.controller.ts @@ -14,6 +14,7 @@ import { GetAuthUser, UserAccessControl } from 'src/auth/auth.guard'; import { TMDB_API_KEY, TMDB_CACHE_TTL } from 'src/consts'; import { User } from 'src/users/user.entity'; import { MetadataService } from '../metadata.service'; +import axios, { AxiosError } from 'axios'; @UseGuards(UserAccessControl) @Controller('tmdb') @@ -63,17 +64,17 @@ export class TmdbController { this.logger.debug(`TMDB proxy cache miss: ${req.method} ${uri}`); - const proxyRes = await fetch(`https://api.themoviedb.org/${uri}`, { + const proxyRes = await axios(`https://api.themoviedb.org/${uri}`, { method: req.method || 'GET', headers: { Authorization: `Bearer ${TMDB_API_KEY}`, }, - }).catch((e) => { - this.logger.error('TMDB Proxy error', e); + }).catch((e: AxiosError) => { + this.logger.error(`TMDB Proxy error: ${e.status}, ${e.message}`); throw e; }); - const json = await proxyRes.json(); + const json = await proxyRes.data; res.status(proxyRes.status); res.json(json); if (req.method === 'GET') diff --git a/backend/src/metadata/tmdb/tmdb.dto.ts b/backend/src/metadata/tmdb/tmdb.dto.ts index 1f3343e..11aeb01 100644 --- a/backend/src/metadata/tmdb/tmdb.dto.ts +++ b/backend/src/metadata/tmdb/tmdb.dto.ts @@ -33,6 +33,10 @@ export type TmdbSeries = Awaited< ReturnType >['data'] +export type TmdbEpisode = Awaited< + ReturnType +>['data']; + export type TmdbMovieFull = TmdbMovie & { videos: MovieVideos; // Proxy or to not proxy credits: MovieCredits; @@ -47,6 +51,8 @@ export type TmdbSeriesFull = TmdbSeries & { images: SeriesImages; }; +export type TmdbEpisodeFull = TmdbEpisode; + class NextEpisodeToAir { @ApiProperty({ required: false }) air_date?: string; diff --git a/backend/src/metadata/tmdb/tmdb.service.ts b/backend/src/metadata/tmdb/tmdb.service.ts index 51b3658..ac01bea 100644 --- a/backend/src/metadata/tmdb/tmdb.service.ts +++ b/backend/src/metadata/tmdb/tmdb.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { TmdbMovieFull, TmdbSeriesFull } from './tmdb.dto'; +import { TmdbEpisodeFull, TmdbMovieFull, TmdbSeriesFull } from './tmdb.dto'; import { TMDB_API, TmdbApi } from './tmdb.providers'; @Injectable() @@ -30,4 +30,21 @@ export class TmdbService { }) .then((r) => r.data as TmdbMovieFull); } + + async getFullEpisode(options: { + tmdbId: string; + season: number; + episode: number; + }) { + return this.tmdbApi.v3 + .tvEpisodeDetails( + Number(options.tmdbId), + options.season, + options.episode, + // { + // append_to_response: 'videos,credits,external_ids,images', + // }, + ) + .then((r) => r.data as TmdbEpisodeFull); + } } diff --git a/backend/src/source-providers/source-provider.dto.ts b/backend/src/source-providers/source-provider.dto.ts index aa83e54..c75aad7 100644 --- a/backend/src/source-providers/source-provider.dto.ts +++ b/backend/src/source-providers/source-provider.dto.ts @@ -191,7 +191,7 @@ export class VideoStreamPropertyDto implements StreamProperty { export class StreamCandidateDto implements StreamCandidate { @ApiProperty() - key: string; + streamId: string; @ApiProperty() title: string; diff --git a/backend/src/source-providers/source-providers.controller.ts b/backend/src/source-providers/source-providers.controller.ts index d257801..28ae677 100644 --- a/backend/src/source-providers/source-providers.controller.ts +++ b/backend/src/source-providers/source-providers.controller.ts @@ -1,4 +1,4 @@ -import { SourceProvider } from '@aleksilassila/reiverr-plugin'; +import { ReiverrPlugin } from '@aleksilassila/reiverr-plugin'; import { Body, Controller, @@ -32,7 +32,7 @@ export class GetSourceProviderPipe implements PipeTransform { constructor(private readonly sourcesService: SourceProvidersService) {} async transform(providerId: string) { - const provider = this.sourcesService.getProvider(providerId); + const provider = this.sourcesService.getPlugin(providerId); if (!provider) { throw new NotFoundException('Plugin not found'); @@ -55,9 +55,7 @@ export class SourceProvidersController { isArray: true, }) async getSourceProviders() { - return this.sourceProvidersService - .getProviders() - .then((plugins) => Object.keys(plugins)); + return Object.keys(this.sourceProvidersService.getProviders()); } @Get(':providerId/settings/template') @@ -69,7 +67,7 @@ export class SourceProvidersController { @Param('providerId') providerId: string, @GetAuthUser() callerUser: User, ): Promise { - const provider = this.sourceProvidersService.getProvider(providerId); + const provider = this.sourceProvidersService.getPlugin(providerId); if (!provider) { throw new NotFoundException('Plugin not found'); @@ -77,7 +75,7 @@ export class SourceProvidersController { // return plugin.getSettingsTemplate(callerUser.pluginSettings?.[sourceId]); return { - settings: provider.settingsManager.getSettingsTemplate(), + settings: provider.getSettingsTemplate(), }; } @@ -91,13 +89,13 @@ export class SourceProvidersController { @Param('providerId') providerId: string, @Body() settings: PluginSettingsDto, ): Promise { - const provider = this.sourceProvidersService.getProvider(providerId); + const provider = this.sourceProvidersService.getPlugin(providerId); if (!provider) { throw new NotFoundException('Plugin not found'); } - return provider.settingsManager.validateSettings(settings.settings); + return provider.validateSettings({ settings: settings.settings }); } /** @deprecated in favor of mediaSource capabilities */ @@ -107,7 +105,7 @@ export class SourceProvidersController { }) async getSourceCapabilities( @GetAuthUser() user: User, - @Param('providerId', GetSourceProviderPipe) provider: SourceProvider, + @Param('providerId', GetSourceProviderPipe) provider: ReiverrPlugin, @GetAuthToken() token: string, ): Promise { // const settings = this.mediaSourcesService.getMediaSourceSettings( @@ -119,12 +117,22 @@ export class SourceProvidersController { // throw new BadRequestException('Source configuration not found'); // } + const mediaSourceProvider = provider.getMediaSourceProvider({ + settings: {}, + sourceId: '', + token: '', + userId: '', + }); + return { - movieIndexing: !!provider.catalogueProvider?.getMovieCatalogue, - episodeIndexing: !!provider.catalogueProvider?.getSeriesCatalogue, - moviePlayback: !!provider.getMovieStreams && !!provider.getMovieStream, + movieIndexing: !!mediaSourceProvider.getMovieCatalogue, + episodeIndexing: !!mediaSourceProvider.getSeriesCatalogue, + moviePlayback: + !!mediaSourceProvider.getTmdbMovieCandidates && + !!mediaSourceProvider.getStream, episodePlayback: - !!provider.getEpisodeStreams && !!provider.getEpisodeStream, + !!mediaSourceProvider.getTmdbEpisodeCandidates && + !!mediaSourceProvider.getStream, }; } } diff --git a/backend/src/source-providers/source-providers.service.ts b/backend/src/source-providers/source-providers.service.ts index 9825923..26fa64b 100644 --- a/backend/src/source-providers/source-providers.service.ts +++ b/backend/src/source-providers/source-providers.service.ts @@ -1,16 +1,15 @@ +import { + getReiverrPluginVersion, + ReiverrPlugin, +} from '@aleksilassila/reiverr-plugin'; import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; -import { - PluginProvider, - SourceProvider, - getReiverrPluginVersion, -} from '@aleksilassila/reiverr-plugin'; @Injectable() export class SourceProvidersService { private logger = new Logger(SourceProvidersService.name); - private providers: Record = {}; + private providers: Record = {}; constructor() { this.logger.log( @@ -27,11 +26,11 @@ export class SourceProvidersService { ); } - async getProviders(): Promise> { + getProviders(): Record { return this.providers; } - private loadPlugins(rootDirectory: string): Record { + private loadPlugins(rootDirectory: string): Record { this.logger.log(`Loading plugins from ${rootDirectory}`); const pluginDirectories = fs.readdirSync(rootDirectory); @@ -45,26 +44,28 @@ export class SourceProvidersService { } } - const plugins: Record = {}; + const plugins: Record = {}; for (const pluginPath of pluginPaths) { try { const supportedPluginVersion = getReiverrPluginVersion(); // eslint-disable-next-line @typescript-eslint/no-var-requires const pluginModule = require(pluginPath); - const provider: PluginProvider = - new pluginModule.default() as PluginProvider; - provider.getPlugins().forEach((plugin) => { - if (plugin._isCompatibleWith(supportedPluginVersion)) { - plugins[plugin.name] = plugin; - } else { - this.logger.warn( - `Plugin ${ - plugin.name - }@${plugin._getPluginVersion()} is not compatible with Reiverr plugin API version ${supportedPluginVersion}`, - ); - } - }); + const providers: ReiverrPlugin | ReiverrPlugin[] = pluginModule.default; + + (Array.isArray(providers) ? providers : [providers]).forEach( + (plugin) => { + if (plugin._isCompatibleWith(supportedPluginVersion)) { + plugins[plugin.name] = plugin; + } else { + this.logger.warn( + `Plugin ${ + plugin.name + }@${plugin.getPluginVersion()} is not compatible with Reiverr plugin API version ${supportedPluginVersion}`, + ); + } + }, + ); } catch (e) { this.logger.error(`Failed to load plugin from ${pluginPath}: ${e}`); } @@ -73,7 +74,7 @@ export class SourceProvidersService { return plugins; } - getProvider(pluginName: string): SourceProvider | undefined { + getPlugin(pluginName: string): ReiverrPlugin | undefined { return this.providers[pluginName]; } } diff --git a/backend/src/user-data/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts index 1384684..fc67c90 100644 --- a/backend/src/user-data/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -23,7 +23,7 @@ import { SuccessResponseDto, } from 'src/common/common.dto'; import { - CatalogueTypeFilter as CatalogueTypeFilter, + CatalogueTypeFilter, LibraryItemDto, MyListOrder, MyListStatusFilter, @@ -68,7 +68,7 @@ export class LibraryController { direction, }); - return response + return response; } @Get('catalogue/:sourceId') @@ -91,6 +91,7 @@ export class LibraryController { ): Promise> { const items = await this.libraryService.getCatalogueItems({ sourceId, + userId, token, pagination, type, diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index 8eb8125..c81dbf4 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -6,19 +6,19 @@ import { } from 'src/common/common.dto'; import { MetadataService } from 'src/metadata/metadata.service'; import { MediaSourcesService } from 'src/users/media-sources/media-sources.service'; -import { Brackets, NotBrackets, Repository } from 'typeorm'; +import { Brackets, Repository } from 'typeorm'; import { PlayState } from '../play-state/play-state.entity'; +import { USER_PLAY_STATE_REPOSITORY } from '../play-state/play-state.providers'; import { - LibraryItemDto, - MyListTypeFilter, - MyListOrder, - OrderDirection, - MyListStatusFilter, CatalogueTypeFilter, + LibraryItemDto, + MyListOrder, + MyListStatusFilter, + MyListTypeFilter, + OrderDirection, } from './library.dto'; import { LibraryItem } from './library.entity'; import { USER_LIBRARY_REPOSITORY } from './library.providers'; -import { USER_PLAY_STATE_REPOSITORY } from '../play-state/play-state.providers'; @Injectable() export class LibraryService { @@ -205,6 +205,7 @@ export class LibraryService { async getCatalogueItems(options: { sourceId: string; + userId: string; token: string; pagination: PaginationDto; type?: CatalogueTypeFilter; @@ -213,6 +214,7 @@ export class LibraryService { }): Promise | undefined> { const { sourceId, + userId, token, pagination, type = CatalogueTypeFilter.All, @@ -220,7 +222,11 @@ export class LibraryService { direction, } = options; - const connection = await this.mediaSourceService.getConnection(sourceId); + const connection = await this.mediaSourceService.getConnection({ + sourceId, + token, + userId, + }); if (!connection) { console.error( @@ -229,18 +235,12 @@ export class LibraryService { throw new Error('No connection found'); } - const combined = connection.provider.catalogueProvider.getCatalogue; - const movies = connection.provider.catalogueProvider.getMovieCatalogue; - const series = connection.provider.catalogueProvider.getSeriesCatalogue; - const missing = connection.provider.catalogueProvider.getMissingInCatalogue; + const combined = connection.provider.getCatalogue; + const movies = connection.provider.getMovieCatalogue; + const series = connection.provider.getSeriesCatalogue; + const missing = connection.provider.getMissingInCatalogue; if (type === CatalogueTypeFilter.All && combined) { const response = await combined({ - context: { - userId: connection.mediaSource.userId, - settings: connection.mediaSource.pluginSettings, - sourceId: connection.mediaSource.id, - token, - }, pagination, order, direction, @@ -254,12 +254,6 @@ export class LibraryService { }; } else if (type === CatalogueTypeFilter.Movies && movies) { const response = await movies({ - context: { - userId: connection.mediaSource.userId, - settings: connection.mediaSource.pluginSettings, - sourceId: connection.mediaSource.id, - token, - }, pagination, order, direction, @@ -273,12 +267,6 @@ export class LibraryService { }; } else if (type === CatalogueTypeFilter.Series && series) { const response = await series({ - context: { - userId: connection.mediaSource.userId, - settings: connection.mediaSource.pluginSettings, - sourceId: connection.mediaSource.id, - token, - }, pagination, order, direction, @@ -308,12 +296,6 @@ export class LibraryService { }); const response = await missing({ - context: { - userId: connection.mediaSource.userId, - settings: connection.mediaSource.pluginSettings, - sourceId: connection.mediaSource.id, - token, - }, pagination, myListItems: tmdbIdToMyListItem, order, diff --git a/backend/src/users/media-sources/media-sources.controller.ts b/backend/src/users/media-sources/media-sources.controller.ts index 5c3d34d..6760742 100644 --- a/backend/src/users/media-sources/media-sources.controller.ts +++ b/backend/src/users/media-sources/media-sources.controller.ts @@ -1,9 +1,4 @@ -import { - EpisodeMetadata, - MovieMetadata, - SourceProvider, - SourceProviderError, -} from '@aleksilassila/reiverr-plugin'; +import { SourceProviderError } from '@aleksilassila/reiverr-plugin'; import { All, BadRequestException, @@ -38,7 +33,6 @@ import { } from 'src/source-providers/source-provider.dto'; import { SourceProvidersService } from 'src/source-providers/source-providers.service'; import { User } from 'src/users/user.entity'; -import { MediaSource } from './media-source.entity'; import { MediaSourcesService } from './media-sources.service'; @Injectable() @@ -55,9 +49,8 @@ export class ServiceOwnershipValidator implements CanActivate { if (!sourceId) return true; - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource) throw new NotFoundException('Source not found'); @@ -79,40 +72,37 @@ export class MediaSourcesController { private metadataService: MetadataService, ) {} - @Get(':sourceId/movies/tmdb/:tmdbId/streams') + @Get(':sourceId/candidates/tmdb/:tmdbId') @ApiOkResponse({ description: 'Movie sources', type: StreamCandidatesDto, }) - async getMovieStreams( + async getTmdbMovieCandidates( @Param('sourceId') sourceId: string, @Param('tmdbId') tmdbId: string, @GetAuthUser() user: User, @GetAuthToken() token: string, ): Promise { - const connection = await this.getConnection(sourceId); - const metadata = await this.getMovieMetadata(tmdbId); + const connection = await this.getConnection({ + sourceId, + userId: user.id, + token, + }); + const tmdbMovie = this.metadataService.getMovieByTmdbId(tmdbId); - const streams = await connection.provider.getMovieStreams?.( - tmdbId, - metadata, - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, - ); + const streams = await connection.provider.getTmdbMovieCandidates?.({ + tmdbMovie: await tmdbMovie.then((m) => m.tmdbMovie), + }); return streams ?? { candidates: [] }; } - @Get(':sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams') + @Get(':sourceId/candidates/tmdb/:tmdbId/season/:season/episode/:episode') @ApiOkResponse({ description: 'Episode sources', type: StreamCandidatesDto, }) - async getEpisodeStreams( + async getTmdbEpisodeCandidates( @Param('sourceId') sourceId: string, @Param('tmdbId') tmdbId: string, @Param('season', ParseIntPipe) season: number, @@ -120,101 +110,49 @@ export class MediaSourcesController { @GetAuthUser() user: User, @GetAuthToken() token: string, ): Promise { - const connection = await this.getConnection(sourceId); - const metadata = await this.getSeriesMetadata(tmdbId, season, episode); - - const streams = await connection.provider.getEpisodeStreams?.( + const connection = await this.getConnection({ + sourceId, + userId: user.id, + token, + }); + const tmdbSeries = this.metadataService.getSeriesByTmdbId(tmdbId); + const tmdbEpisode = this.metadataService.getEpisodeByTmdbId({ tmdbId, - metadata, - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, - ); + season, + episode, + }); + + const streams = await connection.provider.getTmdbEpisodeCandidates?.({ + tmdbSeries: await tmdbSeries.then((s) => s.tmdbSeries), + tmdbEpisode: await tmdbEpisode.then((e) => e.tmdbEpisode), + }); return streams ?? { candidates: [] }; } - @Post(':sourceId/movies/tmdb/:tmdbId/streams/:key') + @Post(':sourceId/stream/:streamId') @ApiOkResponse({ description: 'Movie stream', type: StreamDto, }) - async getMovieStream( - @Param('tmdbId') tmdbId: string, + async getStream( @Param('sourceId') sourceId: string, - @Param('key') key: string, + @Param('streamId') streamId: string, @GetAuthUser() user: User, @GetAuthToken() token: string, @Body() config: PlaybackConfigDto, ): Promise { - const connection = await this.getConnection(sourceId); - const metadata = await this.getMovieMetadata(tmdbId); + const connection = await this.getConnection({ + sourceId, + userId: user.id, + token, + }); const stream = await connection.provider - .getMovieStream?.( - tmdbId, - metadata, - key || '', - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, + .getStream?.({ + streamId, config, - ) - .catch((e) => { - if (e === SourceProviderError.StreamNotFound) { - throw new NotFoundException('Stream not found'); - } else { - console.error(e); - throw new InternalServerErrorException(); - } - }); - - if (!stream) { - throw new NotFoundException('Stream not found'); - } - - return stream; - } - - @Post( - ':sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams/:key', - ) - @ApiOkResponse({ - description: 'Show stream', - type: StreamDto, - }) - async getEpisodeStream( - @Param('sourceId') sourceId: string, - @Param('tmdbId') tmdbId: string, - @Param('season', ParseIntPipe) season: number, - @Param('episode', ParseIntPipe) episode: number, - @Param('key') key: string, - @GetAuthUser() user: User, - @GetAuthToken() token: string, - @Body() config: PlaybackConfigDto, - ): Promise { - const connection = await this.getConnection(sourceId); - const metadata = await this.getSeriesMetadata(tmdbId, season, episode); - - const stream = await connection.provider - .getEpisodeStream?.( - tmdbId, - metadata, - key || '', - { - userId: user.id, - settings: connection.mediaSource.pluginSettings, - token, - sourceId: connection.mediaSource.id, - }, - config, - ) + }) .catch((e) => { if (e === SourceProviderError.StreamNotFound) { throw new NotFoundException('Stream not found'); @@ -242,15 +180,19 @@ export class MediaSourcesController { @GetAuthToken() token: string, ) { const sourceId = params.sourceId; - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource) throw new NotFoundException('Source not found'); - const provider = this.sourceProvidersService.getProvider( - mediaSource.pluginId, - ); + const provider = this.sourceProvidersService + .getPlugin(mediaSource.pluginId) + .getMediaSourceProvider({ + settings: mediaSource.pluginSettings, + sourceId, + token, + userId: user.id, + }); if (!provider) { throw new NotFoundException('Plugin not found'); @@ -262,54 +204,18 @@ export class MediaSourcesController { const targetUrl = query.reiverr_proxy_url || undefined; - await provider.proxyHandler?.(req, res, { - context: { - userId: user.id, - token, - sourceId, - settings: mediaSource.pluginSettings, - }, + await provider.proxyHandler?.({ + req, + res, uri: `/${params[0]}?${req.url.split('?').slice(1).join('?') || ''}`, targetUrl, }); } - async getMovieMetadata(tmdbId: string): Promise { - const metadata = await this.metadataService.getMovieByTmdbId(tmdbId); - - return { - title: metadata.tmdbMovie?.title, - ...(metadata.tmdbMovie.release_date && { - year: new Date(metadata.tmdbMovie.release_date).getFullYear(), - }), - tmdbId, - }; - } - - async getSeriesMetadata( - tmdbId: string, - season: number, - episode: number, - ): Promise { - const metadata = await this.metadataService.getSeriesByTmdbId(tmdbId); - const name = metadata.tmdbSeries?.name; - - if (!name) throw new Error('Could not get metadata for series ' + tmdbId); - - return { - series: name, - tmdbId, - season, - episode, - seasonEpisodes: metadata.tmdbSeries.seasons.find( - (s) => s.season_number === season, - )?.episode_count, - episodeRuntime: metadata.tmdbSeries.last_episode_to_air.runtime, - }; - } - - async getConnection(sourceId: string) { - const connection = await this.mediaSourcesService.getConnection(sourceId); + async getConnection( + ...args: Parameters + ) { + const connection = await this.mediaSourcesService.getConnection(...args); if (!connection) { throw new BadRequestException('Invalid source'); diff --git a/backend/src/users/media-sources/media-sources.service.ts b/backend/src/users/media-sources/media-sources.service.ts index aa489ad..763d00b 100644 --- a/backend/src/users/media-sources/media-sources.service.ts +++ b/backend/src/users/media-sources/media-sources.service.ts @@ -1,5 +1,5 @@ import { - SourceProvider, + MediaSourceProvider, ValidationResponse, } from '@aleksilassila/reiverr-plugin'; import { Inject, Injectable } from '@nestjs/common'; @@ -95,12 +95,12 @@ export class MediaSourcesService { let validationResponse: ValidationResponse | undefined; if (sourceDto.pluginSettings !== undefined) { let valid = false; - const provider = this.sourceProvidersService.getProvider(source.pluginId); + const provider = this.sourceProvidersService.getPlugin(source.pluginId); if (provider) { - validationResponse = await provider.settingsManager.validateSettings( - sourceDto.pluginSettings, - ); + validationResponse = await provider.validateSettings({ + settings: sourceDto.pluginSettings, + }); valid = validationResponse.isValid; source.pluginSettings = validationResponse.settings; } else { @@ -139,18 +139,29 @@ export class MediaSourcesService { ?.find((source) => source.id === sourceId)?.pluginSettings; } - async getConnection(sourceId: string): Promise< + async getConnection(options: { + sourceId: string; + token: string; + userId: string; + }): Promise< | { - provider: SourceProvider; + provider: MediaSourceProvider; mediaSource: MediaSource; } | undefined > { + const { sourceId, token, userId } = options; + const mediaSource = await this.findMediaSource(sourceId); - const provider = this.sourceProvidersService.getProvider( - mediaSource.pluginId, - ); + const provider = this.sourceProvidersService + .getPlugin(mediaSource.pluginId) + .getMediaSourceProvider({ + settings: mediaSource.pluginSettings, + sourceId, + token, + userId, + }); if (provider && mediaSource) { return { provider, mediaSource }; @@ -160,11 +171,16 @@ export class MediaSourcesService { } async getMediaSourceDto(mediaSource: MediaSource): Promise { - const sourceProvider = this.sourceProvidersService.getProvider( + const sourceProvider = this.sourceProvidersService.getPlugin( mediaSource.pluginId, ); - const catalogueProvider = sourceProvider?.catalogueProvider; + const catalogueProvider = sourceProvider?.getMediaSourceProvider({ + userId: '', + settings: {}, + sourceId: '', + token: '', + }); const moviesCatalogue = !!catalogueProvider?.getMovieCatalogue; const seriesCatalogue = !!catalogueProvider?.getSeriesCatalogue; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 8c9ade5..d267902 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -186,7 +186,7 @@ export class UsersService { } private async filterMediaSources(user: User): Promise { - const providers = await this.sourceProvidersService.getProviders(); + const providers = this.sourceProvidersService.getProviders(); return { ...user, diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 538c675..374f120 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -197,7 +197,7 @@ export interface VideoStreamPropertyDto { } export interface StreamCandidateDto { - key: string; + streamId: string; title: string; properties: VideoStreamPropertyDto[]; } @@ -439,7 +439,7 @@ export interface SubtitlesDto { } export interface StreamDto { - key: string; + streamId: string; title: string; properties: VideoStreamPropertyDto[]; src: string; @@ -1062,12 +1062,12 @@ export class Api extends HttpClient + getTmdbMovieCandidates: (sourceId: string, tmdbId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/tmdb/${tmdbId}/streams`, + path: `/api/sources/${sourceId}/candidates/tmdb/${tmdbId}`, method: 'GET', format: 'json', ...params @@ -1077,10 +1077,10 @@ export class Api extends HttpClient extends HttpClient this.request({ - path: `/api/sources/${sourceId}/shows/tmdb/${tmdbId}/season/${season}/episode/${episode}/streams`, + path: `/api/sources/${sourceId}/candidates/tmdb/${tmdbId}/season/${season}/episode/${episode}`, method: 'GET', format: 'json', ...params @@ -1098,43 +1098,17 @@ export class Api extends HttpClient this.request({ - path: `/api/sources/${sourceId}/movies/tmdb/${tmdbId}/streams/${key}`, - method: 'POST', - body: data, - type: ContentType.Json, - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name GetEpisodeStream - * @request POST:/api/sources/{sourceId}/shows/tmdb/{tmdbId}/season/{season}/episode/{episode}/streams/{key} - */ - getEpisodeStream: ( - sourceId: string, - tmdbId: string, - season: number, - episode: number, - key: string, - data: PlaybackConfigDto, - params: RequestParams = {} - ) => - this.request({ - path: `/api/sources/${sourceId}/shows/tmdb/${tmdbId}/season/${season}/episode/${episode}/streams/${key}`, + path: `/api/sources/${sourceId}/stream/${streamId}`, method: 'POST', body: data, type: ContentType.Json, diff --git a/src/lib/components/EpisodeCard/EpisodeCard.svelte b/src/lib/components/EpisodeCard/EpisodeCard.svelte index 188d684..b08794b 100644 --- a/src/lib/components/EpisodeCard/EpisodeCard.svelte +++ b/src/lib/components/EpisodeCard/EpisodeCard.svelte @@ -139,12 +139,10 @@
{#if releaseDate > 0 && releaseDate > Date.now()} - {new Date(releaseDate).toLocaleTimeString('en-US', { - month: 'short', - day: 'numeric', + {new Date(releaseDate).toLocaleDateString('en-US', { weekday: 'short', - hour: 'numeric', - minute: 'numeric' + month: 'short', + day: 'numeric' })} {:else if runtime > 0} {runtime} Min diff --git a/src/lib/components/GlobalBackground/BackgroundStack.svelte b/src/lib/components/GlobalBackground/BackgroundStack.svelte index 6e15cf6..6c0b881 100644 --- a/src/lib/components/GlobalBackground/BackgroundStack.svelte +++ b/src/lib/components/GlobalBackground/BackgroundStack.svelte @@ -82,13 +82,15 @@ on:click={({ detail: e }) => e.stopPropagation()} on:back={() => visibleBackgrounds.destroyVideo()} > - + {#if $visibleBackgrounds.video} + + {/if}
{/key} diff --git a/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte b/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte index 279c2d7..2be3c69 100644 --- a/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte @@ -29,7 +29,7 @@ export let title: string; export let subtitle = ''; export let source: MediaSourceDto; - export let key: string = ''; + export let streamId: string; export let progress: number = 0; type MediaLanguageStore = { @@ -46,11 +46,6 @@ let videoStreamP: Promise; - // const movieP = tmdbApi.getTmdbMovie(Number(tmdbId)).then((r) => { - // title = r?.title || ''; - // subtitle = ''; - // }); - async function reportProgress() { const userId = get(user)?.id; @@ -74,22 +69,14 @@ } const refreshVideoStream = async (audioStreamIndex = 0) => { - console.log('refreshVideoStream', season, episode); - videoStreamP = ( - season !== undefined && episode !== undefined - ? reiverrApi.sources.getEpisodeStream(source.id, tmdbId, season, episode, key, { - // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, - progress, - audioStreamIndex, - deviceProfile: getDeviceProfile() as any - }) - : reiverrApi.sources.getMovieStream(tmdbId, source.id, key, { - // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, - progress, - audioStreamIndex, - deviceProfile: getDeviceProfile() as any - }) - ).then((r) => r.data); + videoStreamP = reiverrApi.sources + .getStream(source.id, streamId, { + // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, + progress, + audioStreamIndex, + deviceProfile: getDeviceProfile() as any + }) + .then((r) => r.data); const stream = await videoStreamP; @@ -137,69 +124,19 @@ // language: s.Language || '' // })) || [], selectAudioTrack: (index: number) => refreshVideoStream(index), - // loadPlaybackInfo({ - // ...options, - // audioStreamIndex: index, - // playbackPosition: progressTime * 10_000_000 - // }), directPlay: stream.directPlay, src: (get(sessions).activeSession?.baseUrl || '') + stream.src, - backdropUrl: - // item?.BackdropImageTags?.length - // ? `${$user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}` - // : - '', + // backdropUrl: '', progress: stream.progress - // (options.playbackPosition || 0) / 10_000_000 || - // (item?.UserData?.PlaybackPositionTicks || 0) / 10_000_000 || - // undefined }; - // title = stream.title; - // subtitle = stream.subtitle; - - // if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId); - if (reportProgressInterval) clearInterval(reportProgressInterval); - reportProgressInterval = setInterval( - () => reportProgress(), - // if (video?.readyState === 4 && progressTime > 0 && sessionId && id) - // reportProgress(id, sessionId, paused, progressTime); - 10_000 - ); + reportProgressInterval = setInterval(() => reportProgress(), 10_000); }; onMount(() => { refreshVideoStream(); }); - /* - title - subtitle - sections - - sourceUri <- quality - playbackPosition - */ - - // $: { - // videoStreamP; - // console.log('videoStreamP', videoStreamP); - // } - - // $: videoStreamP && asd(); - - // const asd = () => - // videoStreamP.then((stream) => { - // // async function loadPlaybackInfo( - // // options: { audioStreamIndex?: number; bitrate?: number; playbackPosition?: number } = {} - // // ) { - // // const item = await itemP; - - // // reportProgressInterval = setInterval(() => { - // // if (video?.readyState === 4 && progressTime > 0 && sessionId && id) - // // reportProgress(id, sessionId, paused, progressTime); - // // }, 10_000); - // }); onDestroy(() => { if (reportProgressInterval) clearInterval(reportProgressInterval); diff --git a/src/lib/stores/media-user-data.store.ts b/src/lib/stores/media-user-data.store.ts index b7cfa16..53abde4 100644 --- a/src/lib/stores/media-user-data.store.ts +++ b/src/lib/stores/media-user-data.store.ts @@ -50,11 +50,11 @@ async function getStreams( ): Promise { return season !== undefined && episode !== undefined ? reiverrApi.sources - .getEpisodeStreams(source.id, tmdbId, season, episode) + .getTmdbEpisodeCandidates(source.id, tmdbId, season, episode) .then((r) => r.data?.candidates ?? []) .catch((e) => []) : reiverrApi.sources - .getMovieStreams(source.id, tmdbId) + .getTmdbMovieCandidates(source.id, tmdbId) .then((r) => r.data?.candidates ?? []) .catch((e) => []); } @@ -66,11 +66,11 @@ async function getAutoplayStream(options: { tmdbId: string; season?: number; epi const firstSource = awaitedStreams.find((p) => p.streams.length > 0); const source = firstSource?.source; - const key = firstSource?.streams[0]?.key; + const streamId = firstSource?.streams[0]?.streamId; return { source, - key + streamId }; } @@ -292,9 +292,9 @@ export function useSeriesUserData(tmdbId: string) { const { season, episode } = videoProps; - const { key, source } = await getAutoplayStream({ tmdbId, season, episode }); + const { streamId, source } = await getAutoplayStream({ tmdbId, season, episode }); - if (!key || !source) { + if (!streamId || !source) { createErrorNotification('Autoplay failed', 'No stream found'); return; } @@ -304,7 +304,7 @@ export function useSeriesUserData(tmdbId: string) { component: TmdbVideoPlayer, props: { ...videoProps, - key, + streamId, source }, mediaId: tmdbId @@ -327,7 +327,7 @@ export function useSeriesUserData(tmdbId: string) { component: TmdbVideoPlayer, props: { ...videoProps, - key: stream.key, + streamId: stream.streamId, source }, mediaId: tmdbId @@ -388,9 +388,9 @@ export function useMovieUserData(tmdbId: string) { tmdbMovie: { subscribe: tmdbMovie.promise.subscribe }, progress, handleAutoplay: async () => { - const { key, source } = await getAutoplayStream({ tmdbId }); + const { streamId, source } = await getAutoplayStream({ tmdbId }); - if (!key || !source) { + if (!streamId || !source) { createErrorNotification('Autoplay failed', 'No stream found'); return; } @@ -400,7 +400,7 @@ export function useMovieUserData(tmdbId: string) { component: TmdbVideoPlayer, props: { ...getVideoProps(), - key, + streamId, source }, mediaId: tmdbId @@ -417,7 +417,7 @@ export function useMovieUserData(tmdbId: string) { component: TmdbVideoPlayer, props: { ...getVideoProps(), - key: stream.key, + streamId: stream.streamId, source }, mediaId: tmdbId @@ -484,9 +484,9 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb progress, handleAutoplay: async () => { // getAutoplayStream({ tmdbId, season, episode, progress: get(progress) }); - const { key, source } = await getAutoplayStream({ tmdbId, season, episode }); + const { streamId, source } = await getAutoplayStream({ tmdbId, season, episode }); - if (!key || !source) { + if (!streamId || !source) { createErrorNotification('Autoplay failed', 'No stream found'); return; } @@ -496,7 +496,7 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb component: TmdbVideoPlayer, props: { ...(await getVideoProps()), - key, + streamId, source }, mediaId: tmdbId @@ -513,7 +513,7 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb component: TmdbVideoPlayer, props: { ...(await getVideoProps()), - key: stream.key, + streamId: stream.streamId, source }, mediaId: tmdbId