diff --git a/backend/README.md b/backend/README.md index 3a48fbe..276066c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -88,6 +88,10 @@ $ npm run test:cov - GET /movies/{tmdbId} - GET /movies/{tmdbId}/similar +- GET /movies/{tmdbId}/sources +- GET /movies/{tmdbId}/sources/{sourceId} +- GET /movies/{tmdbId}/sources/{sourceId}/stream + - GET /series/{tmdbId} - (GET /series/{tmdbId}/season/{season}) - GET /series/{tmdbId}/season/{season}/episode/{episode} diff --git a/backend/plugins/jellyfin.plugin/src/index.ts b/backend/plugins/jellyfin.plugin/src/index.ts index 865257f..719b2b4 100644 --- a/backend/plugins/jellyfin.plugin/src/index.ts +++ b/backend/plugins/jellyfin.plugin/src/index.ts @@ -6,9 +6,13 @@ import { Api as JellyfinApi, } from './jellyfin.openapi'; import { + PlaybackConfig, PluginSettings, PluginSettingsTemplate, SourcePlugin, + Subtitles, + UserContext, + VideoStream, } from 'plugins/plugin-types'; interface JellyfinSettings extends PluginSettings { @@ -17,7 +21,69 @@ interface JellyfinSettings extends PluginSettings { userId: string; } -export const JELLYFIN_DEVICE_ID = 'Reiverr Client'; +interface JellyfinUserContext extends UserContext { + settings: JellyfinSettings; +} + +const JELLYFIN_DEVICE_ID = 'Reiverr Client'; + +const bitrateQualities = [ + { + label: '4K - 120 Mbps', + bitrate: 120000000, + codec: undefined, + }, + { + label: '4K - 80 Mbps', + bitrate: 80000000, + codec: undefined, + }, + { + label: '1080p - 40 Mbps', + bitrate: 40000000, + codec: undefined, + }, + { + label: '1080p - 10 Mbps', + bitrate: 10000000, + codec: undefined, + }, + { + label: '720p - 8 Mbps', + bitrate: 8000000, + codec: undefined, + }, + { + label: '720p - 4 Mbps', + bitrate: 4000000, + codec: undefined, + }, + { + label: '480p - 3 Mbps', + bitrate: 3000000, + codec: undefined, + }, + { + label: '480p - 720 Kbps', + bitrate: 720000, + codec: undefined, + }, + { + label: '360p - 420 Kbps', + bitrate: 420000, + codec: undefined, + }, +]; + +function getClosestBitrate(qualities, bitrate) { + return qualities.reduce( + (prev, curr) => + Math.abs(curr.bitrate - bitrate) < Math.abs(prev.bitrate - bitrate) + ? curr + : prev, + qualities[0], + ); +} @Injectable() export default class JellyfinPlugin implements SourcePlugin { @@ -149,10 +215,18 @@ export default class JellyfinPlugin implements SourcePlugin { async getMovieStream( tmdbId: string, - settings: JellyfinSettings, - ): Promise { - const context = new PluginContext(settings); + userContext: JellyfinUserContext, + config: PlaybackConfig = { + audioStreamIndex: undefined, + bitrate: undefined, + progress: undefined, + defaultLanguage: undefined, + deviceProfile: undefined, + }, + ): Promise { + const context = new PluginContext(userContext.settings, userContext.token); const items = await this.getLibraryItems(context); + const proxyUrl = `/api/movies/${tmdbId}/sources/${this.name}/stream`; const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId); @@ -172,26 +246,99 @@ export default class JellyfinPlugin implements SourcePlugin { ); */ - const playbackInfo = await context.api.items.getPlaybackInfo(movie.Id, { - userId: context.settings.userId, - // deviceId: JELLYFIN_DEVICE_ID, - // mediaSourceId: movie.MediaSources[0].Id, - // maxBitrate: 8000000, - }); + const startTimeTicks = movie.RunTimeTicks + ? movie.RunTimeTicks * config.progress + : undefined; + const maxStreamingBitrate = config.bitrate || 0; //|| movie.MediaSources?.[0]?.Bitrate || 10000000 + + 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, + + // deviceId: JELLYFIN_DEVICE_ID, + // mediaSourceId: movie.MediaSources[0].Id, + // maxBitrate: 8000000, + }, + ); + + const mediasSource = playbackInfo.data?.MediaSources?.[0]; const playbackUri = - playbackInfo.data?.MediaSources?.[0]?.TranscodingUrl || - `/Videos/${playbackInfo.data?.MediaSources?.[0]?.Id}/stream.mp4?Static=true&mediaSourceId=${playbackInfo.data?.MediaSources?.[0]?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${playbackInfo.data?.MediaSources?.[0]?.ETag}`; + proxyUrl + + (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 playbackUri; + const audioStreams: VideoStream['audioStreams'] = + mediasSource?.MediaStreams.filter((s) => s.Type === 'Audio').map((s) => ({ + bitrate: s.BitRate, + label: s.Language, + codec: s.Codec, + index: s.Index, + })) ?? []; + + const qualities = [ + ...bitrateQualities, + { + bitrate: mediasSource.Bitrate, + label: 'Original', + codec: undefined, + }, + ].map((q, i) => ({ + ...q, + index: i, + })); + + const bitrate = Math.min( + maxStreamingBitrate, + movie.MediaSources[0].Bitrate, + ); + + const subtitles: Subtitles[] = mediasSource.MediaStreams.filter( + (s) => s.Type === 'Subtitle' && s.DeliveryUrl, + ).map((s, i) => ({ + index: i, + uri: proxyUrl + s.DeliveryUrl + `reiverr_token=${userContext.token}`, + label: s.DisplayTitle, + codec: s.Codec, + })); + + return { + audioStreamIndex: + config.audioStreamIndex ?? + mediasSource?.DefaultAudioStreamIndex ?? + audioStreams[0].index, + audioStreams, + progress: config.progress ?? 0, + qualities, + quality: getClosestBitrate(qualities, bitrate).index, + subtitles, + uri: playbackUri, + directPlay: + !!mediasSource?.SupportsDirectPlay || + !!mediasSource?.SupportsDirectStream, + }; } } class PluginContext { api: JellyfinApi<{}>; settings: JellyfinSettings; + token: string; - constructor(settings: JellyfinSettings) { + constructor(settings: JellyfinSettings, token = '') { + this.token = token; this.settings = settings; this.api = new JellyfinApi({ baseURL: settings.baseUrl, diff --git a/backend/plugins/plugin-types.d.ts b/backend/plugins/plugin-types.d.ts index 3c1f6c6..ae857ed 100644 --- a/backend/plugins/plugin-types.d.ts +++ b/backend/plugins/plugin-types.d.ts @@ -15,6 +15,11 @@ export type PluginSettingsTemplate = Record< PluginSettingsLink | PluginSettingsInput >; +export type UserContext = { + token: string; + settings: PluginSettings; +}; + export type PluginSettings = Record; export type ValidationResponse = { @@ -23,6 +28,46 @@ export type ValidationResponse = { replace: Record; }; +export type AudioStream = { + index: number; + label: string; + codec: string | undefined; + bitrate: number | undefined; +}; + +export type Quality = { + index: number; + bitrate: number; + label: string; + codec: string | undefined; +}; + +export type Subtitles = { + index: number; + uri: string; + label: string; + codec: string | undefined; +}; + +export type VideoStream = { + uri: string; + directPlay: boolean; + progress: number; + audioStreams: AudioStream[]; + audioStreamIndex: number; + qualities: Quality[]; + quality: number; + subtitles: Subtitles[]; +}; + +export type PlaybackConfig = { + bitrate: number | undefined; + audioStreamIndex: number | undefined; + progress: number | undefined; + deviceProfile: DeviceProfile | undefined; + defaultLanguage: string | undefined; +}; + export interface SourcePlugin { name: string; @@ -46,7 +91,11 @@ export interface SourcePlugin { settings: Record, ) => Promise; - getMovieStream: (tmdbId: string, settings: PluginSettings) => Promise; + getMovieStream: ( + tmdbId: string, + context: UserContext, + config?: PlaybackConfig, + ) => Promise; getEpisodeStream: ( tmdbId: string, @@ -63,3 +112,214 @@ export interface SourcePlugin { headers: any; }; } + +/** + * A MediaBrowser.Model.Dlna.DeviceProfile represents a set of metadata which determines which content a certain device is able to play. + *
+ * Specifically, it defines the supported containers and + * codecs (video and/or audio, including codec profiles and levels) + * the device is able to direct play (without transcoding or remuxing), + * as well as which containers/codecs to transcode to in case it isn't. + */ +export interface DeviceProfile { + /** Gets or sets the name of this device profile. User profiles must have a unique name. */ + Name?: string | null; + /** + * Gets or sets the unique internal identifier. + * @format uuid + */ + Id?: string | null; + /** + * Gets or sets the maximum allowed bitrate for all streamed content. + * @format int32 + */ + MaxStreamingBitrate?: number | null; + /** + * Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files). + * @format int32 + */ + MaxStaticBitrate?: number | null; + /** + * Gets or sets the maximum allowed bitrate for transcoded music streams. + * @format int32 + */ + MusicStreamingTranscodingBitrate?: number | null; + /** + * Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files. + * @format int32 + */ + MaxStaticMusicBitrate?: number | null; + /** Gets or sets the direct play profiles. */ + DirectPlayProfiles?: DirectPlayProfile[]; + /** Gets or sets the transcoding profiles. */ + TranscodingProfiles?: TranscodingProfile[]; + /** Gets or sets the container profiles. Failing to meet these optional conditions causes transcoding to occur. */ + ContainerProfiles?: ContainerProfile[]; + /** Gets or sets the codec profiles. */ + CodecProfiles?: CodecProfile[]; + /** Gets or sets the subtitle profiles. */ + SubtitleProfiles?: SubtitleProfile[]; +} + +/** Defines the MediaBrowser.Model.Dlna.DirectPlayProfile. */ +export interface DirectPlayProfile { + /** Gets or sets the container. */ + Container?: string; + /** Gets or sets the audio codec. */ + AudioCodec?: string | null; + /** Gets or sets the video codec. */ + VideoCodec?: string | null; + /** Gets or sets the Dlna profile type. */ + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; +} + +/** A class for transcoding profile information. */ +export interface TranscodingProfile { + /** Gets or sets the container. */ + Container?: string; + /** Gets or sets the DLNA profile type. */ + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; + /** Gets or sets the video codec. */ + VideoCodec?: string; + /** Gets or sets the audio codec. */ + AudioCodec?: string; + /** + * Media streaming protocol. + * Lowercase for backwards compatibility. + */ + Protocol?: 'http' | 'hls'; + /** + * Gets or sets a value indicating whether the content length should be estimated. + * @default false + */ + EstimateContentLength?: boolean; + /** + * Gets or sets a value indicating whether M2TS mode is enabled. + * @default false + */ + EnableMpegtsM2TsMode?: boolean; + /** + * Gets or sets the transcoding seek info mode. + * @default "Auto" + */ + TranscodeSeekInfo?: 'Auto' | 'Bytes'; + /** + * Gets or sets a value indicating whether timestamps should be copied. + * @default false + */ + CopyTimestamps?: boolean; + /** + * Gets or sets the encoding context. + * @default "Streaming" + */ + Context?: 'Streaming' | 'Static'; + /** + * Gets or sets a value indicating whether subtitles are allowed in the manifest. + * @default false + */ + EnableSubtitlesInManifest?: boolean; + /** Gets or sets the maximum audio channels. */ + MaxAudioChannels?: string | null; + /** + * Gets or sets the minimum amount of segments. + * @format int32 + * @default 0 + */ + MinSegments?: number; + /** + * Gets or sets the segment length. + * @format int32 + * @default 0 + */ + SegmentLength?: number; + /** + * Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported. + * @default false + */ + BreakOnNonKeyFrames?: boolean; + /** Gets or sets the profile conditions. */ + Conditions?: ProfileCondition[]; + /** + * Gets or sets a value indicating whether variable bitrate encoding is supported. + * @default true + */ + EnableAudioVbrEncoding?: boolean; +} + +export interface ProfileCondition { + Condition?: + | 'Equals' + | 'NotEquals' + | 'LessThanEqual' + | 'GreaterThanEqual' + | 'EqualsAny'; + Property?: + | 'AudioChannels' + | 'AudioBitrate' + | 'AudioProfile' + | 'Width' + | 'Height' + | 'Has64BitOffsets' + | 'PacketLength' + | 'VideoBitDepth' + | 'VideoBitrate' + | 'VideoFramerate' + | 'VideoLevel' + | 'VideoProfile' + | 'VideoTimestamp' + | 'IsAnamorphic' + | 'RefFrames' + | 'NumAudioStreams' + | 'NumVideoStreams' + | 'IsSecondaryAudio' + | 'VideoCodecTag' + | 'IsAvc' + | 'IsInterlaced' + | 'AudioSampleRate' + | 'AudioBitDepth' + | 'VideoRangeType'; + Value?: string | null; + IsRequired?: boolean; +} + +/** Defines the MediaBrowser.Model.Dlna.ContainerProfile. */ +export interface ContainerProfile { + /** Gets or sets the MediaBrowser.Model.Dlna.DlnaProfileType which this container must meet. */ + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; + /** Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition which this container will be applied to. */ + Conditions?: ProfileCondition[]; + /** Gets or sets the container(s) which this container must meet. */ + Container?: string | null; + /** Gets or sets the sub container(s) which this container must meet. */ + SubContainer?: string | null; +} + +/** Defines the MediaBrowser.Model.Dlna.CodecProfile. */ +export interface CodecProfile { + /** Gets or sets the MediaBrowser.Model.Dlna.CodecType which this container must meet. */ + Type?: 'Video' | 'VideoAudio' | 'Audio'; + /** Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition which this profile must meet. */ + Conditions?: ProfileCondition[]; + /** Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition to apply if this profile is met. */ + ApplyConditions?: ProfileCondition[]; + /** Gets or sets the codec(s) that this profile applies to. */ + Codec?: string | null; + /** Gets or sets the container(s) which this profile will be applied to. */ + Container?: string | null; + /** Gets or sets the sub-container(s) which this profile will be applied to. */ + SubContainer?: string | null; +} + +/** A class for subtitle profile information. */ +export interface SubtitleProfile { + /** Gets or sets the format. */ + Format?: string | null; + /** Gets or sets the delivery method. */ + Method?: 'Encode' | 'Embed' | 'External' | 'Hls' | 'Drop'; + /** Gets or sets the DIDL mode. */ + DidlMode?: string | null; + /** Gets or sets the language. */ + Language?: string | null; + /** Gets or sets the container. */ + Container?: string | null; +} diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index b716d13..f7b34c7 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -10,6 +10,7 @@ import { ENV, JWT_SECRET } from '../consts'; import { AccessTokenPayload } from './auth.service'; import { User } from '../users/user.entity'; import { UsersService } from '../users/users.service'; +import { Request } from 'express'; export const GetUser = createParamDecorator( (data: unknown, ctx: ExecutionContext): User => { @@ -18,10 +19,24 @@ export const GetUser = createParamDecorator( }, ); -function extractTokenFromHeader(request: Request): string | undefined { +export const GetAuthToken = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string | undefined => { + const request = ctx.switchToHttp().getRequest(); + return extractTokenFromRequest(request); + }, +); + +function extractTokenFromRequest(request: Request): string | undefined { const [type, token] = (request.headers as any).authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; + + let v = type === 'Bearer' ? token : undefined; + + if (v) return v; + + return request.query['reiverr_token'] + ? (request.query['reiverr_token'] as string) + : undefined; } @Injectable() @@ -33,7 +48,7 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const token = extractTokenFromHeader(request); + const token = extractTokenFromRequest(request); if (ENV === 'development' && !token) { request['user'] = await this.userService.findOneByName('test'); @@ -71,7 +86,7 @@ export class OptionalAuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const token = extractTokenFromHeader(request); + const token = extractTokenFromRequest(request); if (!token) { return true; } diff --git a/backend/src/source-plugins/device-profile.dto.ts b/backend/src/source-plugins/device-profile.dto.ts new file mode 100644 index 0000000..d72beb7 --- /dev/null +++ b/backend/src/source-plugins/device-profile.dto.ts @@ -0,0 +1,476 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DirectPlayProfileDto { + @ApiProperty({ + required: false, + description: 'Gets or sets the container.', + nullable: true, + }) + Container?: string; + + @ApiProperty({ + required: false, + description: 'Gets or sets the audio codec.', + nullable: true, + }) + AudioCodec?: string | null; + + @ApiProperty({ + required: false, + description: 'Gets or sets the video codec.', + nullable: true, + }) + VideoCodec?: string | null; + + @ApiProperty({ + required: false, + description: 'Gets or sets the Dlna profile type.', + enum: ['Audio', 'Video', 'Photo', 'Subtitle', 'Lyric'], + }) + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; +} + +export class ProfileConditionDto { + @ApiProperty({ + description: 'Gets or sets the condition.', + enum: [ + 'Equals', + 'NotEquals', + 'LessThanEqual', + 'GreaterThanEqual', + 'EqualsAny', + ], + nullable: true, + required: false, + }) + Condition?: + | 'Equals' + | 'NotEquals' + | 'LessThanEqual' + | 'GreaterThanEqual' + | 'EqualsAny'; + + @ApiProperty({ + description: 'Gets or sets the property.', + enum: [ + 'AudioChannels', + 'AudioBitrate', + 'AudioProfile', + 'Width', + 'Height', + 'Has64BitOffsets', + 'PacketLength', + 'VideoBitDepth', + 'VideoBitrate', + 'VideoFramerate', + 'VideoLevel', + 'VideoProfile', + 'VideoTimestamp', + 'IsAnamorphic', + 'RefFrames', + 'NumAudioStreams', + 'NumVideoStreams', + 'IsSecondaryAudio', + 'VideoCodecTag', + 'IsAvc', + 'IsInterlaced', + 'AudioSampleRate', + 'AudioBitDepth', + 'VideoRangeType', + ], + nullable: true, + required: false, + }) + Property?: + | 'AudioChannels' + | 'AudioBitrate' + | 'AudioProfile' + | 'Width' + | 'Height' + | 'Has64BitOffsets' + | 'PacketLength' + | 'VideoBitDepth' + | 'VideoBitrate' + | 'VideoFramerate' + | 'VideoLevel' + | 'VideoProfile' + | 'VideoTimestamp' + | 'IsAnamorphic' + | 'RefFrames' + | 'NumAudioStreams' + | 'NumVideoStreams' + | 'IsSecondaryAudio' + | 'VideoCodecTag' + | 'IsAvc' + | 'IsInterlaced' + | 'AudioSampleRate' + | 'AudioBitDepth' + | 'VideoRangeType' + | null; + + @ApiProperty({ + description: 'Gets or sets the value.', + nullable: true, + required: false, + }) + Value?: string | null; + + @ApiProperty({ + description: 'Indicates if the condition is required.', + nullable: true, + required: false, + }) + IsRequired?: boolean; +} + +export class TranscodingProfileDto { + @ApiProperty({ + description: 'Gets or sets the container.', + nullable: true, + required: false, + }) + Container?: string; + + @ApiProperty({ + description: 'Gets or sets the DLNA profile type.', + enum: ['Audio', 'Video', 'Photo', 'Subtitle', 'Lyric'], + required: false, + }) + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; + + @ApiProperty({ + description: 'Gets or sets the video codec.', + nullable: true, + required: false, + }) + VideoCodec?: string; + + @ApiProperty({ + description: 'Gets or sets the audio codec.', + nullable: true, + required: false, + }) + AudioCodec?: string; + + @ApiProperty({ + description: 'Media streaming protocol.', + enum: ['http', 'hls'], + required: false, + }) + Protocol?: 'http' | 'hls'; + + @ApiProperty({ + description: 'Indicates if the content length should be estimated.', + default: false, + required: false, + }) + EstimateContentLength?: boolean; + + @ApiProperty({ + description: 'Indicates if M2TS mode is enabled.', + default: false, + required: false, + }) + EnableMpegtsM2TsMode?: boolean; + + @ApiProperty({ + description: 'Gets or sets the transcoding seek info mode.', + default: 'Auto', + enum: ['Auto', 'Bytes'], + required: false, + }) + TranscodeSeekInfo?: 'Auto' | 'Bytes'; + + @ApiProperty({ + description: 'Indicates if timestamps should be copied.', + default: false, + required: false, + }) + CopyTimestamps?: boolean; + + @ApiProperty({ + description: 'Gets or sets the encoding context.', + default: 'Streaming', + enum: ['Streaming', 'Static'], + required: false, + }) + Context?: 'Streaming' | 'Static'; + + @ApiProperty({ + description: 'Indicates if subtitles are allowed in the manifest.', + default: false, + required: false, + }) + EnableSubtitlesInManifest?: boolean; + + @ApiProperty({ + description: 'Gets or sets the maximum audio channels.', + nullable: true, + required: false, + }) + MaxAudioChannels?: string | null; + + @ApiProperty({ + description: 'Gets or sets the minimum amount of segments.', + format: 'int32', + default: 0, + required: false, + }) + MinSegments?: number; + + @ApiProperty({ + description: 'Gets or sets the segment length.', + format: 'int32', + default: 0, + required: false, + }) + SegmentLength?: number; + + @ApiProperty({ + description: + 'Indicates if breaking the video stream on non-keyframes is supported.', + default: false, + required: false, + }) + BreakOnNonKeyFrames?: boolean; + + @ApiProperty({ + description: 'Gets or sets the profile conditions.', + type: [ProfileConditionDto], + nullable: true, + required: false, + }) + Conditions?: ProfileConditionDto[]; + + @ApiProperty({ + description: 'Indicates if variable bitrate encoding is supported.', + default: true, + required: false, + }) + EnableAudioVbrEncoding?: boolean; +} + +export class ContainerProfileDto { + @ApiProperty({ + description: + 'Gets or sets the MediaBrowser.Model.Dlna.DlnaProfileType which this container must meet.', + enum: ['Audio', 'Video', 'Photo', 'Subtitle', 'Lyric'], + nullable: true, + required: false, + }) + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; + + @ApiProperty({ + description: 'Gets or sets the profile conditions.', + type: [ProfileConditionDto], + nullable: true, + required: false, + }) + Conditions?: ProfileConditionDto[]; + + @ApiProperty({ + description: + 'Gets or sets the container(s) which this container must meet.', + nullable: true, + required: false, + }) + Container?: string | null; + + @ApiProperty({ + description: + 'Gets or sets the sub container(s) which this container must meet.', + nullable: true, + required: false, + }) + SubContainer?: string | null; +} + +export class CodecProfileDto { + @ApiProperty({ + description: + 'Gets or sets the MediaBrowser.Model.Dlna.CodecType which this container must meet.', + enum: ['Video', 'VideoAudio', 'Audio'], + nullable: true, + required: false, + }) + Type?: 'Video' | 'VideoAudio' | 'Audio'; + + @ApiProperty({ + description: 'Gets or sets the profile conditions.', + type: [ProfileConditionDto], + nullable: true, + required: false, + }) + Conditions?: ProfileConditionDto[]; + + @ApiProperty({ + description: 'Gets or sets the apply conditions if this profile is met.', + type: [ProfileConditionDto], + nullable: true, + required: false, + }) + ApplyConditions?: ProfileConditionDto[]; + + @ApiProperty({ + description: 'Gets or sets the codec(s) that this profile applies to.', + nullable: true, + required: false, + }) + Codec?: string | null; + + @ApiProperty({ + description: + 'Gets or sets the container(s) which this profile will be applied to.', + nullable: true, + required: false, + }) + Container?: string | null; + + @ApiProperty({ + description: + 'Gets or sets the sub-container(s) which this profile will be applied to.', + nullable: true, + required: false, + }) + SubContainer?: string | null; +} + +export class SubtitleProfileDto { + @ApiProperty({ + description: 'Gets or sets the format.', + nullable: true, + required: false, + }) + Format?: string | null; + + @ApiProperty({ + description: 'Gets or sets the delivery method.', + enum: ['Encode', 'Embed', 'External', 'Hls', 'Drop'], + nullable: true, + required: false, + }) + Method?: 'Encode' | 'Embed' | 'External' | 'Hls' | 'Drop'; + + @ApiProperty({ + description: 'Gets or sets the DIDL mode.', + nullable: true, + required: false, + }) + DidlMode?: string | null; + + @ApiProperty({ + description: 'Gets or sets the language.', + nullable: true, + required: false, + }) + Language?: string | null; + + @ApiProperty({ + description: 'Gets or sets the container.', + nullable: true, + required: false, + }) + Container?: string | null; +} + +/** + * A MediaBrowser.Model.Dlna.DeviceProfile represents a set of metadata which determines which content a certain device is able to play. + *
+ * Specifically, it defines the supported containers and + * codecs (video and/or audio, including codec profiles and levels) + * the device is able to direct play (without transcoding or remuxing), + * as well as which containers/codecs to transcode to in case it isn't. + */ +export class DeviceProfileDto { + @ApiProperty({ + description: + 'Gets or sets the name of this device profile. User profiles must have a unique name.', + nullable: true, + required: false, + }) + Name?: string | null; + + @ApiProperty({ + description: 'Gets or sets the unique internal identifier.', + format: 'uuid', + nullable: true, + required: false, + }) + Id?: string | null; + + @ApiProperty({ + description: + 'Gets or sets the maximum allowed bitrate for all streamed content.', + format: 'int32', + nullable: true, + required: false, + }) + MaxStreamingBitrate?: number | null; + + @ApiProperty({ + description: + 'Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files).', + format: 'int32', + nullable: true, + required: false, + }) + MaxStaticBitrate?: number | null; + + @ApiProperty({ + description: + 'Gets or sets the maximum allowed bitrate for transcoded music streams.', + format: 'int32', + nullable: true, + required: false, + }) + MusicStreamingTranscodingBitrate?: number | null; + + @ApiProperty({ + description: + 'Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files.', + format: 'int32', + nullable: true, + required: false, + }) + MaxStaticMusicBitrate?: number | null; + + @ApiProperty({ + description: 'Gets or sets the direct play profiles.', + type: [DirectPlayProfileDto], + nullable: true, + required: false, + }) + DirectPlayProfiles?: DirectPlayProfileDto[]; + + @ApiProperty({ + description: 'Gets or sets the transcoding profiles.', + type: [TranscodingProfileDto], + nullable: true, + required: false, + }) + TranscodingProfiles?: TranscodingProfileDto[]; + + @ApiProperty({ + description: 'Gets or sets the container profiles.', + type: [ContainerProfileDto], + nullable: true, + required: false, + }) + ContainerProfiles?: ContainerProfileDto[]; + + @ApiProperty({ + description: 'Gets or sets the codec profiles.', + type: [CodecProfileDto], + nullable: true, + required: false, + }) + CodecProfiles?: CodecProfileDto[]; + + @ApiProperty({ + description: 'Gets or sets the subtitle profiles.', + type: [SubtitleProfileDto], + nullable: true, + required: false, + }) + SubtitleProfiles?: SubtitleProfileDto[]; +} diff --git a/backend/src/source-plugins/source-plugins.controller.ts b/backend/src/source-plugins/source-plugins.controller.ts index b689f9b..bd01651 100644 --- a/backend/src/source-plugins/source-plugins.controller.ts +++ b/backend/src/source-plugins/source-plugins.controller.ts @@ -6,7 +6,9 @@ import { Get, NotFoundException, Param, + ParseIntPipe, Post, + Query, Req, Res, UnauthorizedException, @@ -14,22 +16,24 @@ import { } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { SourcePluginsService } from './source-plugins.service'; -import { AuthGuard, GetUser } from 'src/auth/auth.guard'; +import { AuthGuard, GetAuthToken, GetUser } from 'src/auth/auth.guard'; import { Request, Response } from 'express'; import { Readable } from 'stream'; import { User } from 'src/users/user.entity'; import { UserSourcesService } from 'src/users/user-sources/user-sources.service'; import { PluginSettingsTemplate } from 'plugins/plugin-types'; import { + PlaybackConfigDto, PluginSettingsDto, PluginSettingsTemplateDto, - ValidationResponsekDto, + SourceListDto as VideoStreamListDto, + ValidationResponsekDto as ValidationResponseDto, + VideoStreamDto, } from './source-plugins.dto'; export const JELLYFIN_DEVICE_ID = 'Reiverr Client'; -@ApiTags('sources') -@Controller('sources') +@Controller() @UseGuards(AuthGuard) export class SourcesController { constructor( @@ -37,7 +41,8 @@ export class SourcesController { private userSourcesService: UserSourcesService, ) {} - @Get() + @ApiTags('sources') + @Get('sources') @ApiOkResponse({ description: 'All source plugins found', type: String, @@ -45,11 +50,12 @@ export class SourcesController { }) async getSourcePlugins() { return this.sourcesService - .getLoadedPlugins() + .getPlugins() .then((plugins) => Object.keys(plugins)); } - @Get(':sourceId/settings/template') + @ApiTags('sources') + @Get('sources/:sourceId/settings/template') @ApiOkResponse({ description: 'Source settings template', type: PluginSettingsTemplateDto, @@ -70,16 +76,17 @@ export class SourcesController { }; } - @Post(':sourceId/settings/validate') + @ApiTags('sources') + @Post('sources/:sourceId/settings/validate') @ApiOkResponse({ description: 'Source settings validation', - type: ValidationResponsekDto, + type: ValidationResponseDto, }) async validateSourceSettings( @GetUser() callerUser: User, @Param('sourceId') sourceId: string, @Body() settings: PluginSettingsDto, - ): Promise { + ): Promise { const plugin = this.sourcesService.getPlugin(sourceId); if (!plugin) { @@ -89,12 +96,64 @@ export class SourcesController { return plugin.validateSettings(settings.settings); } - @Get(':sourceId/movies/:tmdbId/stream') + @ApiTags('movies') + @Get('movies/:tmdbId/sources') + @ApiOkResponse({ + description: 'Movie sources', + type: VideoStreamListDto, + }) + async getMovieSources( + @Param('tmdbId') tmdbId: string, + @GetUser() user: User, + @GetAuthToken() token: string, + ): Promise { + if (!user) { + throw new UnauthorizedException(); + } + + const plugins = await this.sourcesService.getPlugins(); + const sources: VideoStreamListDto['sources'] = {}; + + for (const pluginId in plugins) { + const plugin = plugins[pluginId]; + + if (!plugin) continue; + + const settings = this.userSourcesService.getSourceSettings( + user, + pluginId, + ); + + if (!settings) continue; + + const videoStream = await plugin.getMovieStream(tmdbId, { + settings, + token, + }); + + if (!videoStream) continue; + + sources[pluginId] = videoStream; + } + + return { + sources, + }; + } + + @ApiTags('movies') + @Post('movies/:tmdbId/sources/:sourceId/stream') + @ApiOkResponse({ + description: 'Movie stream', + type: VideoStreamDto, + }) async getMovieStream( @Param('sourceId') sourceId: string, @Param('tmdbId') tmdbId: string, @GetUser() user: User, - ) { + @GetAuthToken() token: string, + @Body() config: PlaybackConfigDto, + ): Promise { if (!user) { throw new UnauthorizedException(); } @@ -105,12 +164,18 @@ export class SourcesController { throw new BadRequestException('Source configuration not found'); } - return this.sourcesService - .getPlugin(sourceId) - ?.getMovieStream(tmdbId, settings); + return this.sourcesService.getPlugin(sourceId)?.getMovieStream( + tmdbId, + { + settings, + token, + }, + config, + ); } - @All(':sourceId/movies/:tmdbId/stream/*') + @ApiTags('movies') + @All('movies/:tmdbId/sources/:sourceId/stream/*') async getMovieStreamProxy( @Param() params: any, @Req() req: Request, @@ -119,6 +184,9 @@ export class SourcesController { ) { const sourceId = params.sourceId; const settings = this.userSourcesService.getSourceSettings(user, sourceId); + + if (!settings) throw new UnauthorizedException(); + const { url, headers } = this.sourcesService .getPlugin(sourceId) ?.handleProxy( @@ -133,7 +201,7 @@ export class SourcesController { method: req.method || 'GET', headers: { ...headers, - Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`, + // Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`, }, }); diff --git a/backend/src/source-plugins/source-plugins.dto.ts b/backend/src/source-plugins/source-plugins.dto.ts index fada83a..24af0eb 100644 --- a/backend/src/source-plugins/source-plugins.dto.ts +++ b/backend/src/source-plugins/source-plugins.dto.ts @@ -4,12 +4,18 @@ import { getSchemaPath, } from '@nestjs/swagger'; import { + AudioStream, + PlaybackConfig, PluginSettings, PluginSettingsInput, PluginSettingsLink, PluginSettingsTemplate, + Quality, + Subtitles, ValidationResponse, + VideoStream, } from 'plugins/plugin-types'; +import { DeviceProfileDto } from './device-profile.dto'; class PluginSettingsLinkDto implements PluginSettingsLink { @ApiProperty({ example: 'link', enum: ['link'] }) @@ -91,3 +97,109 @@ export class ValidationResponsekDto implements ValidationResponse { }) replace: Record; } + +export class SourceDto { + @ApiProperty({ example: '/path/to/stream' }) + uri: string; +} + +export class SourceListDto { + @ApiProperty({ + type: 'object', + additionalProperties: { $ref: getSchemaPath(SourceDto) }, + example: { + source1: { uri: '/path/to/stream' }, + source2: { uri: '/path/to/other/stream' }, + }, + }) + sources: Record; +} + +export class AudioStreamDto implements AudioStream { + @ApiProperty() + index: number; + + @ApiProperty() + label: string; + + @ApiProperty({ example: 'aac', required: false }) + codec: string | undefined; + + @ApiProperty({ example: 96_000, type: 'number', required: false }) + bitrate: number | undefined; +} + +export class QualityDto implements Quality { + @ApiProperty() + index: number; + + @ApiProperty() + bitrate: number; + + @ApiProperty() + label: string; + + @ApiProperty({ required: false }) + codec: string | undefined; +} + +export class SubtitlesDto implements Subtitles { + @ApiProperty() + index: number; + + @ApiProperty() + uri: string; + + @ApiProperty() + label: string; + + @ApiProperty({ required: false }) + codec: string | undefined; +} + +export class VideoStreamDto implements VideoStream { + @ApiProperty() + uri: string; + + @ApiProperty() + directPlay: boolean; + + @ApiProperty() + progress: number; + + @ApiProperty({ type: [AudioStreamDto] }) + audioStreams: AudioStreamDto[]; + + @ApiProperty() + audioStreamIndex: number; + + @ApiProperty({ type: [QualityDto] }) + qualities: QualityDto[]; + + @ApiProperty() + quality: number; + + @ApiProperty({ type: [SubtitlesDto] }) + subtitles: SubtitlesDto[]; +} + +export class PlaybackConfigDto implements PlaybackConfig { + @ApiPropertyOptional({ example: 0, required: false }) + bitrate: number | undefined; + + @ApiPropertyOptional({ example: 0, required: false }) + audioStreamIndex: number | undefined; + + @ApiPropertyOptional({ example: 0, required: false }) + progress: number | undefined; + + @ApiPropertyOptional({ + example: 'en', + required: false, + type: DeviceProfileDto, + }) + deviceProfile: DeviceProfileDto | undefined; + + @ApiPropertyOptional({ example: 'en', required: false }) + defaultLanguage: string | undefined; +} diff --git a/backend/src/source-plugins/source-plugins.service.ts b/backend/src/source-plugins/source-plugins.service.ts index 95834f0..ad84a6f 100644 --- a/backend/src/source-plugins/source-plugins.service.ts +++ b/backend/src/source-plugins/source-plugins.service.ts @@ -19,7 +19,7 @@ export class SourcePluginsService { ); } - async getLoadedPlugins(): Promise> { + async getPlugins(): Promise> { return this.plugins; } diff --git a/backend/src/users/user-sources/user-sources.service.ts b/backend/src/users/user-sources/user-sources.service.ts index 21dc245..d423caa 100644 --- a/backend/src/users/user-sources/user-sources.service.ts +++ b/backend/src/users/user-sources/user-sources.service.ts @@ -89,7 +89,8 @@ export class UserSourcesService { } getSourceSettings(user: User, sourceId: string) { - return user.mediaSources?.find((source) => source.id === sourceId) - ?.pluginSettings; + return user.mediaSources + ?.filter((s) => s?.enabled) + ?.find((source) => source.id === sourceId)?.pluginSettings; } } diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 36317f8..fec1c61 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -65,7 +65,6 @@ export class UsersController { @Param('id') id: string, @GetUser() callerUser: User, ): Promise { - console.log('callerUser', callerUser); if (!callerUser.isAdmin && callerUser.id !== id) { throw new NotFoundException(); } diff --git a/package-lock.json b/package-lock.json index 155bd09..4285f2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3285,9 +3285,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001571", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001571.tgz", - "integrity": "sha512-tYq/6MoXhdezDLFZuCO/TKboTzuQ/xR5cFdgXPfDtM7/kchBO3b4VWghE/OAi/DV7tTdhmLjZiZBZi1fA/GheQ==", + "version": "1.0.30001687", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz", + "integrity": "sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==", "dev": true, "funding": [ { @@ -3302,7 +3302,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chai": { "version": "4.3.7", diff --git a/package.json b/package.json index 0ff7934..4db6bff 100644 --- a/package.json +++ b/package.json @@ -94,4 +94,4 @@ } ] } -} \ No newline at end of file +} diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 7dc9ee0..fe1490c 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -120,6 +120,253 @@ export interface ValidationResponsekDto { replace: Record; } +export interface SourceListDto { + /** @example {"source1":{"uri":"/path/to/stream"},"source2":{"uri":"/path/to/other/stream"}} */ + sources: Record; +} + +export interface DirectPlayProfileDto { + /** Gets or sets the container. */ + Container?: string | null; + /** Gets or sets the audio codec. */ + AudioCodec?: string | null; + /** Gets or sets the video codec. */ + VideoCodec?: string | null; + /** Gets or sets the Dlna profile type. */ + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; +} + +export interface ProfileConditionDto { + /** Gets or sets the condition. */ + Condition?: 'Equals' | 'NotEquals' | 'LessThanEqual' | 'GreaterThanEqual' | 'EqualsAny' | null; + /** Gets or sets the property. */ + Property?: + | 'AudioChannels' + | 'AudioBitrate' + | 'AudioProfile' + | 'Width' + | 'Height' + | 'Has64BitOffsets' + | 'PacketLength' + | 'VideoBitDepth' + | 'VideoBitrate' + | 'VideoFramerate' + | 'VideoLevel' + | 'VideoProfile' + | 'VideoTimestamp' + | 'IsAnamorphic' + | 'RefFrames' + | 'NumAudioStreams' + | 'NumVideoStreams' + | 'IsSecondaryAudio' + | 'VideoCodecTag' + | 'IsAvc' + | 'IsInterlaced' + | 'AudioSampleRate' + | 'AudioBitDepth' + | 'VideoRangeType' + | null; + /** Gets or sets the value. */ + Value?: string | null; + /** Indicates if the condition is required. */ + IsRequired?: boolean | null; +} + +export interface TranscodingProfileDto { + /** Gets or sets the container. */ + Container?: string | null; + /** Gets or sets the DLNA profile type. */ + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; + /** Gets or sets the video codec. */ + VideoCodec?: string | null; + /** Gets or sets the audio codec. */ + AudioCodec?: string | null; + /** Media streaming protocol. */ + Protocol?: 'http' | 'hls'; + /** + * Indicates if the content length should be estimated. + * @default false + */ + EstimateContentLength?: boolean; + /** + * Indicates if M2TS mode is enabled. + * @default false + */ + EnableMpegtsM2TsMode?: boolean; + /** + * Gets or sets the transcoding seek info mode. + * @default "Auto" + */ + TranscodeSeekInfo?: 'Auto' | 'Bytes'; + /** + * Indicates if timestamps should be copied. + * @default false + */ + CopyTimestamps?: boolean; + /** + * Gets or sets the encoding context. + * @default "Streaming" + */ + Context?: 'Streaming' | 'Static'; + /** + * Indicates if subtitles are allowed in the manifest. + * @default false + */ + EnableSubtitlesInManifest?: boolean; + /** Gets or sets the maximum audio channels. */ + MaxAudioChannels?: string | null; + /** + * Gets or sets the minimum amount of segments. + * @format int32 + * @default 0 + */ + MinSegments?: number; + /** + * Gets or sets the segment length. + * @format int32 + * @default 0 + */ + SegmentLength?: number; + /** + * Indicates if breaking the video stream on non-keyframes is supported. + * @default false + */ + BreakOnNonKeyFrames?: boolean; + /** Gets or sets the profile conditions. */ + Conditions?: ProfileConditionDto[] | null; + /** + * Indicates if variable bitrate encoding is supported. + * @default true + */ + EnableAudioVbrEncoding?: boolean; +} + +export interface ContainerProfileDto { + /** Gets or sets the MediaBrowser.Model.Dlna.DlnaProfileType which this container must meet. */ + Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric' | null; + /** Gets or sets the profile conditions. */ + Conditions?: ProfileConditionDto[] | null; + /** Gets or sets the container(s) which this container must meet. */ + Container?: string | null; + /** Gets or sets the sub container(s) which this container must meet. */ + SubContainer?: string | null; +} + +export interface CodecProfileDto { + /** Gets or sets the MediaBrowser.Model.Dlna.CodecType which this container must meet. */ + Type?: 'Video' | 'VideoAudio' | 'Audio' | null; + /** Gets or sets the profile conditions. */ + Conditions?: ProfileConditionDto[] | null; + /** Gets or sets the apply conditions if this profile is met. */ + ApplyConditions?: ProfileConditionDto[] | null; + /** Gets or sets the codec(s) that this profile applies to. */ + Codec?: string | null; + /** Gets or sets the container(s) which this profile will be applied to. */ + Container?: string | null; + /** Gets or sets the sub-container(s) which this profile will be applied to. */ + SubContainer?: string | null; +} + +export interface SubtitleProfileDto { + /** Gets or sets the format. */ + Format?: string | null; + /** Gets or sets the delivery method. */ + Method?: 'Encode' | 'Embed' | 'External' | 'Hls' | 'Drop' | null; + /** Gets or sets the DIDL mode. */ + DidlMode?: string | null; + /** Gets or sets the language. */ + Language?: string | null; + /** Gets or sets the container. */ + Container?: string | null; +} + +export interface DeviceProfileDto { + /** Gets or sets the name of this device profile. User profiles must have a unique name. */ + Name?: string | null; + /** + * Gets or sets the unique internal identifier. + * @format uuid + */ + Id?: string | null; + /** + * Gets or sets the maximum allowed bitrate for all streamed content. + * @format int32 + */ + MaxStreamingBitrate?: number | null; + /** + * Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files). + * @format int32 + */ + MaxStaticBitrate?: number | null; + /** + * Gets or sets the maximum allowed bitrate for transcoded music streams. + * @format int32 + */ + MusicStreamingTranscodingBitrate?: number | null; + /** + * Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files. + * @format int32 + */ + MaxStaticMusicBitrate?: number | null; + /** Gets or sets the direct play profiles. */ + DirectPlayProfiles?: DirectPlayProfileDto[] | null; + /** Gets or sets the transcoding profiles. */ + TranscodingProfiles?: TranscodingProfileDto[] | null; + /** Gets or sets the container profiles. */ + ContainerProfiles?: ContainerProfileDto[] | null; + /** Gets or sets the codec profiles. */ + CodecProfiles?: CodecProfileDto[] | null; + /** Gets or sets the subtitle profiles. */ + SubtitleProfiles?: SubtitleProfileDto[] | null; +} + +export interface PlaybackConfigDto { + /** @example 0 */ + bitrate?: number; + /** @example 0 */ + audioStreamIndex?: number; + /** @example 0 */ + progress?: number; + /** @example "en" */ + deviceProfile?: DeviceProfileDto; + /** @example "en" */ + defaultLanguage?: string; +} + +export interface AudioStreamDto { + index: number; + label: string; + /** @example "aac" */ + codec?: string; + /** @example 96000 */ + bitrate?: number; +} + +export interface QualityDto { + index: number; + bitrate: number; + label: string; + codec?: string; +} + +export interface SubtitlesDto { + index: number; + uri: string; + label: string; + codec?: string; +} + +export interface VideoStreamDto { + uri: string; + directPlay: boolean; + progress: number; + audioStreams: AudioStreamDto[]; + audioStreamIndex: number; + qualities: QualityDto[]; + quality: number; + subtitles: SubtitlesDto[]; +} + import type { AxiosInstance, AxiosRequestConfig, @@ -532,32 +779,56 @@ export class Api extends HttpClient + this.request({ + path: `/api/movies/${tmdbId}/sources`, + method: 'GET', + format: 'json', + ...params }), /** * No description * - * @tags sources + * @tags movies * @name GetMovieStream - * @request GET:/api/sources/{sourceId}/movies/{tmdbId}/stream + * @request POST:/api/movies/{tmdbId}/sources/{sourceId}/stream */ - getMovieStream: (sourceId: string, tmdbId: string, params: RequestParams = {}) => - this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream`, - method: 'GET', + getMovieStream: ( + sourceId: string, + tmdbId: string, + data: PlaybackConfigDto, + params: RequestParams = {} + ) => + this.request({ + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', ...params }), /** * No description * - * @tags sources + * @tags movies * @name GetMovieStreamProxyGet - * @request GET:/api/sources/{sourceId}/movies/{tmdbId}/stream/* + * @request GET:/api/movies/{tmdbId}/sources/{sourceId}/stream/* */ - getMovieStreamProxyGet: (sourceId: string, tmdbId: string, params: RequestParams = {}) => + getMovieStreamProxyGet: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'GET', ...params }), @@ -565,13 +836,13 @@ export class Api extends HttpClient + getMovieStreamProxyPost: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'POST', ...params }), @@ -579,13 +850,13 @@ export class Api extends HttpClient + getMovieStreamProxyPut: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'PUT', ...params }), @@ -593,13 +864,13 @@ export class Api extends HttpClient + getMovieStreamProxyDelete: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'DELETE', ...params }), @@ -607,13 +878,13 @@ export class Api extends HttpClient + getMovieStreamProxyPatch: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'PATCH', ...params }), @@ -621,13 +892,13 @@ export class Api extends HttpClient + getMovieStreamProxyOptions: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'OPTIONS', ...params }), @@ -635,13 +906,13 @@ export class Api extends HttpClient + getMovieStreamProxyHead: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'HEAD', ...params }), @@ -649,13 +920,13 @@ export class Api extends HttpClient + getMovieStreamProxySearch: (tmdbId: string, sourceId: string, params: RequestParams = {}) => this.request({ - path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, method: 'SEARCH', ...params }) diff --git a/src/lib/components/Card/Card.svelte b/src/lib/components/Card/Card.svelte index e9a3127..1985fb2 100644 --- a/src/lib/components/Card/Card.svelte +++ b/src/lib/components/Card/Card.svelte @@ -134,7 +134,7 @@ - {#if jellyfinId} + {#if progress}
+ import { get } from 'svelte/store'; + import { sessions } from '../../stores/session.store'; + import { reiverrApiNew } from '../../stores/user.store'; + import type { PlaybackInfo, VideoPlayerContext } from './VideoPlayer'; + import VideoPlayerModal from './VideoPlayerModal.svelte'; + import { getQualities } from '../../apis/jellyfin/qualities'; + import getDeviceProfile from '../../apis/jellyfin/playback-profiles'; + import type { VideoStreamDto } from '../../apis/reiverr/reiverr.openapi'; + import { tmdbApi } from '../../apis/tmdb/tmdb-api'; + + export let tmdbId: string; + export let sourceId: string; + + export let modalId: symbol; + export let hidden: boolean = false; + + let title: string = ''; + let subtitle: string = ''; + + let sourceUri = ''; + + let playerContext: VideoPlayerContext | undefined; + + let videoStreamP: Promise; + + const movieP = tmdbApi.getTmdbMovie(Number(tmdbId)).then((r) => { + title = r?.title || ''; + subtitle = ''; + }); + + const refreshVideoStream = async (audioStreamIndex = 0) => { + console.log('called2'); + videoStreamP = reiverrApiNew.movies + .getMovieStream(sourceId, tmdbId, { + // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, + progress: 0, + audioStreamIndex, + deviceProfile: getDeviceProfile() as any + }) + .then((r) => r.data) + .then((d) => ({ + ...d, + uri: d.uri + })); + + await videoStreamP; + }; + + refreshVideoStream(); + /* + title + subtitle + sections + + sourceUri <- quality + playbackPosition + */ + + + diff --git a/src/lib/components/VideoPlayer/VideoPlayer.ts b/src/lib/components/VideoPlayer/VideoPlayer.ts index 2b9cb57..58116b93 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.ts +++ b/src/lib/components/VideoPlayer/VideoPlayer.ts @@ -1,7 +1,12 @@ import { writable } from 'svelte/store'; import { modalStack } from '../Modal/modal.store'; import { jellyfinItemsStore } from '../../stores/data.store'; -import VideoPlayerModal from './JellyfinVideoPlayerModal.svelte'; +import JellyfinVideoPlayerModal from './JellyfinVideoPlayerModal.svelte'; +import { reiverrApiNew } from '../../stores/user.store'; +import { createErrorNotification } from '../Notifications/notification.store'; +import VideoPlayerModal from './VideoPlayerModal.svelte'; +import { sources } from '../../stores/sources.store'; +import MovieVideoPlayerModal from './MovieVideoPlayerModal.svelte'; export type SubtitleInfo = { subtitles?: Subtitles; @@ -21,6 +26,12 @@ export type AudioTrack = { index: number; }; +export interface VideoPlayerContext { + title?: string; + subtitle?: string; + playbackInfo?: PlaybackInfo; +} + export type PlaybackInfo = { playbackUrl: string; directPlay: boolean; @@ -32,26 +43,45 @@ export type PlaybackInfo = { selectAudioTrack: (index: number) => void; }; -const initialValue = { visible: false, jellyfinId: '' }; +const initialValue = { visible: false, jellyfinId: '', sourceId: '' }; export type PlayerStateValue = typeof initialValue; -function createPlayerState() { +function usePlayerState() { const store = writable(initialValue); + async function streamMovie(tmdbId: string, sourceId: string = '') { + if (!sourceId) { + const sources = await reiverrApiNew.movies.getMovieSources(tmdbId).then((r) => r.data); + sourceId = Object.keys(sources.sources)[0] || ''; + } + + if (!sourceId) { + createErrorNotification('Could not find a suitable source'); + return; + } + + store.set({ visible: true, jellyfinId: tmdbId, sourceId }); + modalStack.create(MovieVideoPlayerModal, { + tmdbId, + sourceId + }); + } + return { ...store, + streamMovie, streamJellyfinId: (id: string) => { - store.set({ visible: true, jellyfinId: id }); - modalStack.create(VideoPlayerModal, { id }); + store.set({ visible: true, jellyfinId: id, sourceId: '' }); + modalStack.create(JellyfinVideoPlayerModal, { id }); }, close: () => { - store.set({ visible: false, jellyfinId: '' }); + store.set({ visible: false, jellyfinId: '', sourceId: '' }); jellyfinItemsStore.send(); } }; } -export const playerState = createPlayerState(); +export const playerState = usePlayerState(); export function getBrowserSpecificMediaFunctions() { // These functions are different in every browser diff --git a/src/lib/components/VideoPlayer/VideoPlayerModal.svelte b/src/lib/components/VideoPlayer/VideoPlayerModal.svelte new file mode 100644 index 0000000..6f97523 --- /dev/null +++ b/src/lib/components/VideoPlayer/VideoPlayerModal.svelte @@ -0,0 +1,367 @@ + + + + + diff --git a/src/lib/pages/ManagePage/EditPluginDialog.ManagePage.svelte b/src/lib/pages/ManagePage/EditPluginDialog.ManagePage.svelte index fe9df0f..d118967 100644 --- a/src/lib/pages/ManagePage/EditPluginDialog.ManagePage.svelte +++ b/src/lib/pages/ManagePage/EditPluginDialog.ManagePage.svelte @@ -52,6 +52,7 @@ async function handleRemovePlugin() { await reiverrApiNew.users.deleteSource(plugin, get(user)?.id || ''); + await user.refreshUser(); modalStack.close(modalId); } diff --git a/src/lib/pages/MoviePage.svelte b/src/lib/pages/MoviePage.svelte index aee1cda..3cc8c7c 100644 --- a/src/lib/pages/MoviePage.svelte +++ b/src/lib/pages/MoviePage.svelte @@ -174,7 +174,7 @@