mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-17 21:53:12 +02:00
feat: Experimental movie playback through plugins
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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<string> {
|
||||
const context = new PluginContext(settings);
|
||||
userContext: JellyfinUserContext,
|
||||
config: PlaybackConfig = {
|
||||
audioStreamIndex: undefined,
|
||||
bitrate: undefined,
|
||||
progress: undefined,
|
||||
defaultLanguage: undefined,
|
||||
deviceProfile: undefined,
|
||||
},
|
||||
): Promise<VideoStream> {
|
||||
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,
|
||||
|
||||
262
backend/plugins/plugin-types.d.ts
vendored
262
backend/plugins/plugin-types.d.ts
vendored
@@ -15,6 +15,11 @@ export type PluginSettingsTemplate = Record<
|
||||
PluginSettingsLink | PluginSettingsInput
|
||||
>;
|
||||
|
||||
export type UserContext = {
|
||||
token: string;
|
||||
settings: PluginSettings;
|
||||
};
|
||||
|
||||
export type PluginSettings = Record<string, any>;
|
||||
|
||||
export type ValidationResponse = {
|
||||
@@ -23,6 +28,46 @@ export type ValidationResponse = {
|
||||
replace: Record<string, any>;
|
||||
};
|
||||
|
||||
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<string, any>,
|
||||
) => Promise<ValidationResponse>;
|
||||
|
||||
getMovieStream: (tmdbId: string, settings: PluginSettings) => Promise<any>;
|
||||
getMovieStream: (
|
||||
tmdbId: string,
|
||||
context: UserContext,
|
||||
config?: PlaybackConfig,
|
||||
) => Promise<VideoStream>;
|
||||
|
||||
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.
|
||||
* <br />
|
||||
* Specifically, it defines the supported <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.ContainerProfiles">containers</see> and
|
||||
* <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.CodecProfiles">codecs</see> (video and/or audio, including codec profiles and levels)
|
||||
* the device is able to direct play (without transcoding or remuxing),
|
||||
* as well as which <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.TranscodingProfiles">containers/codecs to transcode to</see> 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;
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = extractTokenFromHeader(request);
|
||||
const token = extractTokenFromRequest(request);
|
||||
if (!token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
476
backend/src/source-plugins/device-profile.dto.ts
Normal file
476
backend/src/source-plugins/device-profile.dto.ts
Normal file
@@ -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.
|
||||
* <br />
|
||||
* Specifically, it defines the supported <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.ContainerProfiles">containers</see> and
|
||||
* <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.CodecProfiles">codecs</see> (video and/or audio, including codec profiles and levels)
|
||||
* the device is able to direct play (without transcoding or remuxing),
|
||||
* as well as which <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.TranscodingProfiles">containers/codecs to transcode to</see> 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[];
|
||||
}
|
||||
@@ -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<ValidationResponsekDto> {
|
||||
): Promise<ValidationResponseDto> {
|
||||
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<VideoStreamListDto> {
|
||||
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<VideoStreamDto> {
|
||||
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}"`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, VideoStreamDto>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class SourcePluginsService {
|
||||
);
|
||||
}
|
||||
|
||||
async getLoadedPlugins(): Promise<Record<string, SourcePlugin>> {
|
||||
async getPlugins(): Promise<Record<string, SourcePlugin>> {
|
||||
return this.plugins;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export class UsersController {
|
||||
@Param('id') id: string,
|
||||
@GetUser() callerUser: User,
|
||||
): Promise<UserDto> {
|
||||
console.log('callerUser', callerUser);
|
||||
if (!callerUser.isAdmin && callerUser.id !== id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -94,4 +94,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,253 @@ export interface ValidationResponsekDto {
|
||||
replace: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SourceListDto {
|
||||
/** @example {"source1":{"uri":"/path/to/stream"},"source2":{"uri":"/path/to/other/stream"}} */
|
||||
sources: Record<string, any>;
|
||||
}
|
||||
|
||||
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<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
type: ContentType.Json,
|
||||
format: 'json',
|
||||
...params
|
||||
})
|
||||
};
|
||||
movies = {
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags movies
|
||||
* @name GetMovieSources
|
||||
* @request GET:/api/movies/{tmdbId}/sources
|
||||
*/
|
||||
getMovieSources: (tmdbId: string, params: RequestParams = {}) =>
|
||||
this.request<SourceListDto, any>({
|
||||
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<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream`,
|
||||
method: 'GET',
|
||||
getMovieStream: (
|
||||
sourceId: string,
|
||||
tmdbId: string,
|
||||
data: PlaybackConfigDto,
|
||||
params: RequestParams = {}
|
||||
) =>
|
||||
this.request<VideoStreamDto, any>({
|
||||
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<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'GET',
|
||||
...params
|
||||
}),
|
||||
@@ -565,13 +836,13 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @tags movies
|
||||
* @name GetMovieStreamProxyPost
|
||||
* @request POST:/api/sources/{sourceId}/movies/{tmdbId}/stream/*
|
||||
* @request POST:/api/movies/{tmdbId}/sources/{sourceId}/stream/*
|
||||
*/
|
||||
getMovieStreamProxyPost: (sourceId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
getMovieStreamProxyPost: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'POST',
|
||||
...params
|
||||
}),
|
||||
@@ -579,13 +850,13 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @tags movies
|
||||
* @name GetMovieStreamProxyPut
|
||||
* @request PUT:/api/sources/{sourceId}/movies/{tmdbId}/stream/*
|
||||
* @request PUT:/api/movies/{tmdbId}/sources/{sourceId}/stream/*
|
||||
*/
|
||||
getMovieStreamProxyPut: (sourceId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
getMovieStreamProxyPut: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'PUT',
|
||||
...params
|
||||
}),
|
||||
@@ -593,13 +864,13 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @tags movies
|
||||
* @name GetMovieStreamProxyDelete
|
||||
* @request DELETE:/api/sources/{sourceId}/movies/{tmdbId}/stream/*
|
||||
* @request DELETE:/api/movies/{tmdbId}/sources/{sourceId}/stream/*
|
||||
*/
|
||||
getMovieStreamProxyDelete: (sourceId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
getMovieStreamProxyDelete: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'DELETE',
|
||||
...params
|
||||
}),
|
||||
@@ -607,13 +878,13 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @tags movies
|
||||
* @name GetMovieStreamProxyPatch
|
||||
* @request PATCH:/api/sources/{sourceId}/movies/{tmdbId}/stream/*
|
||||
* @request PATCH:/api/movies/{tmdbId}/sources/{sourceId}/stream/*
|
||||
*/
|
||||
getMovieStreamProxyPatch: (sourceId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
getMovieStreamProxyPatch: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'PATCH',
|
||||
...params
|
||||
}),
|
||||
@@ -621,13 +892,13 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @tags movies
|
||||
* @name GetMovieStreamProxyOptions
|
||||
* @request OPTIONS:/api/sources/{sourceId}/movies/{tmdbId}/stream/*
|
||||
* @request OPTIONS:/api/movies/{tmdbId}/sources/{sourceId}/stream/*
|
||||
*/
|
||||
getMovieStreamProxyOptions: (sourceId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
getMovieStreamProxyOptions: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'OPTIONS',
|
||||
...params
|
||||
}),
|
||||
@@ -635,13 +906,13 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @tags movies
|
||||
* @name GetMovieStreamProxyHead
|
||||
* @request HEAD:/api/sources/{sourceId}/movies/{tmdbId}/stream/*
|
||||
* @request HEAD:/api/movies/{tmdbId}/sources/{sourceId}/stream/*
|
||||
*/
|
||||
getMovieStreamProxyHead: (sourceId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
getMovieStreamProxyHead: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'HEAD',
|
||||
...params
|
||||
}),
|
||||
@@ -649,13 +920,13 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
|
||||
/**
|
||||
* No description
|
||||
*
|
||||
* @tags sources
|
||||
* @tags movies
|
||||
* @name GetMovieStreamProxySearch
|
||||
* @request SEARCH:/api/sources/{sourceId}/movies/{tmdbId}/stream/*
|
||||
* @request SEARCH:/api/movies/{tmdbId}/sources/{sourceId}/stream/*
|
||||
*/
|
||||
getMovieStreamProxySearch: (sourceId: string, tmdbId: string, params: RequestParams = {}) =>
|
||||
getMovieStreamProxySearch: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
|
||||
this.request<void, any>({
|
||||
path: `/api/sources/${sourceId}/movies/${tmdbId}/stream/*`,
|
||||
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`,
|
||||
method: 'SEARCH',
|
||||
...params
|
||||
})
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- Play Button -->
|
||||
{#if jellyfinId}
|
||||
<!-- {#if jellyfinId}
|
||||
<div class="absolute inset-0 flex items-center justify-center z-[1]">
|
||||
<PlayButton
|
||||
on:click={(e) => {
|
||||
@@ -144,7 +144,7 @@
|
||||
class="sm:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if} -->
|
||||
{#if progress}
|
||||
<div
|
||||
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 bg-gradient-to-t ease-in-out z-[1]"
|
||||
|
||||
69
src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte
Normal file
69
src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
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<VideoStreamDto>;
|
||||
|
||||
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
|
||||
*/
|
||||
</script>
|
||||
|
||||
<VideoPlayerModal
|
||||
{...$$props}
|
||||
{modalId}
|
||||
{hidden}
|
||||
{videoStreamP}
|
||||
{refreshVideoStream}
|
||||
{title}
|
||||
{subtitle}
|
||||
/>
|
||||
@@ -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<PlayerStateValue>(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
|
||||
|
||||
367
src/lib/components/VideoPlayer/VideoPlayerModal.svelte
Normal file
367
src/lib/components/VideoPlayer/VideoPlayerModal.svelte
Normal file
@@ -0,0 +1,367 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import Container from '../../../Container.svelte';
|
||||
import VideoPlayer from './VideoPlayer.svelte';
|
||||
import type { PlaybackInfo, Subtitles, SubtitleInfo, AudioTrack } from './VideoPlayer';
|
||||
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
|
||||
import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
|
||||
import { getQualities } from '../../apis/jellyfin/qualities';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { modalStack, modalStackTop } from '../Modal/modal.store';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { ISO_2_LANGUAGES } from '../../utils/iso-2-languages';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import { reiverrApiNew, user } from '../../stores/user.store';
|
||||
import { reiverrApi } from '../../apis/reiverr/reiverr-api';
|
||||
import type { VideoStreamDto } from '../../apis/reiverr/reiverr.openapi';
|
||||
import { sessions } from '../../stores/session.store';
|
||||
|
||||
type MediaLanguageStore = {
|
||||
subtitles?: string;
|
||||
audio?: string;
|
||||
};
|
||||
|
||||
export let videoStreamP: Promise<VideoStreamDto>;
|
||||
export let refreshVideoStream: (audioStreamIndex?: number) => Promise<void>;
|
||||
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean = false;
|
||||
|
||||
// const itemP = jellyfinApi.getLibraryItem(id);
|
||||
|
||||
export let title: string = '';
|
||||
export let subtitle: string = '';
|
||||
// itemP.then((item) => {
|
||||
// title = item?.Name || '';
|
||||
// subtitle = `${item?.SeriesName || ''} S${item?.ParentIndexNumber || ''}E${
|
||||
// item?.IndexNumber || ''
|
||||
// }`;
|
||||
// });
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
let paused: boolean;
|
||||
let progressTime: number;
|
||||
|
||||
let playbackInfo: PlaybackInfo | undefined;
|
||||
let subtitleInfo: SubtitleInfo | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
let reportProgressInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
const reportProgress = () => {};
|
||||
|
||||
$: {
|
||||
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;
|
||||
const mediaLanguagesStore = createLocalStorageStore<MediaLanguageStore>(
|
||||
'media-tracks-' + title,
|
||||
{}
|
||||
);
|
||||
// const storedAudioStreamIndex = item?.MediaStreams?.find(
|
||||
// (s) => s.Type === 'Audio' && s.Language === mediaLanguagesStore.get().audio
|
||||
// )?.Index;
|
||||
// const audioStreamIndex = options.audioStreamIndex ?? storedAudioStreamIndex ?? undefined;
|
||||
|
||||
// const jellyfinPlaybackInfo = await jellyfinApi.getPlaybackInfo(
|
||||
// id,
|
||||
// getDeviceProfile(),
|
||||
// options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
// options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
// audioStreamIndex
|
||||
// );
|
||||
|
||||
// if (!item || !jellyfinPlaybackInfo) {
|
||||
// console.error('No item or playback info', item, jellyfinPlaybackInfo);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const { playbackUri, playSessionId, mediaSourceId, directPlay } = jellyfinPlaybackInfo;
|
||||
|
||||
// if (!playbackUri || !playSessionId) {
|
||||
// console.error('No playback URL or session ID', playbackUri, playSessionId);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// sessionId = playSessionId;
|
||||
|
||||
// const mediaSource = jellyfinPlaybackInfo.MediaSources?.[0];
|
||||
|
||||
// const storedSubtitlesLang = mediaLanguagesStore.get().subtitles;
|
||||
|
||||
// if (options.audioStreamIndex) {
|
||||
// const audioLang = mediaSource?.MediaStreams?.[options.audioStreamIndex]?.Language;
|
||||
// mediaLanguagesStore.update((prev) => ({
|
||||
// ...prev,
|
||||
// audio: audioLang || undefined
|
||||
// }));
|
||||
// }
|
||||
|
||||
let subtitles: Subtitles | undefined;
|
||||
// for (const stream of mediaSource?.MediaStreams || []) {
|
||||
// if (
|
||||
// stream.Type === 'Subtitle' &&
|
||||
// (storedSubtitlesLang !== undefined
|
||||
// ? stream.Language === storedSubtitlesLang
|
||||
// : stream.IsDefault)
|
||||
// ) {
|
||||
// subtitles = {
|
||||
// kind: 'subtitles',
|
||||
// srclang: stream.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${stream.Index}/${stream.Level}/Stream.vtt`,
|
||||
// // @ts-ignore
|
||||
// language: ISO_2_LANGUAGES[stream?.Language || '']?.name || 'English'
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
const availableSubtitles: Subtitles[] = stream.subtitles.map((s) => ({
|
||||
kind: 'subtitles',
|
||||
srclang: s.label,
|
||||
url: get(sessions).activeSession?.baseUrl + s.uri,
|
||||
language: s.label
|
||||
}));
|
||||
// =
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Subtitle').map((s) => ({
|
||||
// kind: 'subtitles' as const,
|
||||
// srclang: s.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${s.Index}/${s.Level}/Stream.vtt`,
|
||||
// language: 'English'
|
||||
// })) || [];
|
||||
|
||||
const selectSubtitles = (subtitles?: Subtitles) => {
|
||||
mediaLanguagesStore.update((prev) => ({
|
||||
...prev,
|
||||
subtitles: subtitles?.srclang || ''
|
||||
}));
|
||||
|
||||
if (subtitleInfo) {
|
||||
if (subtitles)
|
||||
subtitleInfo = {
|
||||
...subtitleInfo,
|
||||
subtitles
|
||||
};
|
||||
else
|
||||
subtitleInfo = {
|
||||
...subtitleInfo,
|
||||
subtitles: undefined
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
subtitleInfo = {
|
||||
subtitles,
|
||||
availableSubtitles,
|
||||
selectSubtitles
|
||||
};
|
||||
|
||||
playbackInfo = {
|
||||
audioStreamIndex: 0, // audioStreamIndex ?? mediaSource?.DefaultAudioStreamIndex ?? -1,
|
||||
audioTracks: [],
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Audio').map((s) => ({
|
||||
// index: s.Index || -1,
|
||||
// language: s.Language || ''
|
||||
// })) || [],
|
||||
selectAudioTrack: (index: number) => refreshVideoStream(index),
|
||||
// loadPlaybackInfo({
|
||||
// ...options,
|
||||
// audioStreamIndex: index,
|
||||
// playbackPosition: progressTime * 10_000_000
|
||||
// }),
|
||||
directPlay: stream.directPlay,
|
||||
playbackUrl: (get(sessions).activeSession?.baseUrl || '') + stream.uri,
|
||||
backdrop:
|
||||
// item?.BackdropImageTags?.length
|
||||
// ? `${$user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
|
||||
// :
|
||||
'',
|
||||
startTime: stream.progress
|
||||
// (options.playbackPosition || 0) / 10_000_000 ||
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0) / 10_000_000 ||
|
||||
// undefined
|
||||
};
|
||||
|
||||
// if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
if (reportProgressInterval) clearInterval(reportProgressInterval);
|
||||
// reportProgressInterval = setInterval(() => {
|
||||
// if (video?.readyState === 4 && progressTime > 0 && sessionId && id)
|
||||
// reportProgress(id, sessionId, paused, progressTime);
|
||||
// }, 10_000);
|
||||
});
|
||||
|
||||
// const reportPlaybackStarted = (id: string, sessionId: string, mediaSourceId: string) =>
|
||||
// jellyfinApi.reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
// const reportProgress = (id: string, sessionId: string, paused: boolean, progressTime: number) =>
|
||||
// jellyfinApi.reportPlaybackProgress(id, sessionId, paused, progressTime * 10_000_000);
|
||||
|
||||
// const deleteEncoding = (sessionId: string) => jellyfinApi.deleteActiveEncoding(sessionId);
|
||||
|
||||
// const reportPlaybackStopped = (id: string, sessionId: string, progressTime: number) => {
|
||||
// jellyfinApi.reportPlaybackStopped(id, sessionId, progressTime * 10_000_000);
|
||||
// deleteEncoding(sessionId);
|
||||
// };
|
||||
|
||||
// async function loadPlaybackInfo(
|
||||
// options: { audioStreamIndex?: number; bitrate?: number; playbackPosition?: number } = {}
|
||||
// ) {
|
||||
// const item = await itemP;
|
||||
// const mediaLanguagesStore = createLocalStorageStore<MediaLanguageStore>(
|
||||
// 'media-tracks-' + (item?.SeriesName || id),
|
||||
// {}
|
||||
// );
|
||||
// const storedAudioStreamIndex = item?.MediaStreams?.find(
|
||||
// (s) => s.Type === 'Audio' && s.Language === mediaLanguagesStore.get().audio
|
||||
// )?.Index;
|
||||
// const audioStreamIndex = options.audioStreamIndex ?? storedAudioStreamIndex ?? undefined;
|
||||
|
||||
// const jellyfinPlaybackInfo = await jellyfinApi.getPlaybackInfo(
|
||||
// id,
|
||||
// getDeviceProfile(),
|
||||
// options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
// options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
// audioStreamIndex
|
||||
// );
|
||||
|
||||
// if (!item || !jellyfinPlaybackInfo) {
|
||||
// console.error('No item or playback info', item, jellyfinPlaybackInfo);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const { playbackUri, playSessionId, mediaSourceId, directPlay } = jellyfinPlaybackInfo;
|
||||
|
||||
// if (!playbackUri || !playSessionId) {
|
||||
// console.error('No playback URL or session ID', playbackUri, playSessionId);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// sessionId = playSessionId;
|
||||
|
||||
// const mediaSource = jellyfinPlaybackInfo.MediaSources?.[0];
|
||||
|
||||
// const storedSubtitlesLang = mediaLanguagesStore.get().subtitles;
|
||||
|
||||
// if (options.audioStreamIndex) {
|
||||
// const audioLang = mediaSource?.MediaStreams?.[options.audioStreamIndex]?.Language;
|
||||
// mediaLanguagesStore.update((prev) => ({
|
||||
// ...prev,
|
||||
// audio: audioLang || undefined
|
||||
// }));
|
||||
// }
|
||||
|
||||
// let subtitles: Subtitles | undefined;
|
||||
// for (const stream of mediaSource?.MediaStreams || []) {
|
||||
// if (
|
||||
// stream.Type === 'Subtitle' &&
|
||||
// (storedSubtitlesLang !== undefined
|
||||
// ? stream.Language === storedSubtitlesLang
|
||||
// : stream.IsDefault)
|
||||
// ) {
|
||||
// subtitles = {
|
||||
// kind: 'subtitles',
|
||||
// srclang: stream.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${stream.Index}/${stream.Level}/Stream.vtt`,
|
||||
// // @ts-ignore
|
||||
// language: ISO_2_LANGUAGES[stream?.Language || '']?.name || 'English'
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
// const availableSubtitles =
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Subtitle').map((s) => ({
|
||||
// kind: 'subtitles' as const,
|
||||
// srclang: s.Language || '',
|
||||
// url: `${$user?.settings.jellyfin.baseUrl}/Videos/${id}/${mediaSource?.Id}/Subtitles/${s.Index}/${s.Level}/Stream.vtt`,
|
||||
// language: 'English'
|
||||
// })) || [];
|
||||
|
||||
// const selectSubtitles = (subtitles?: Subtitles) => {
|
||||
// mediaLanguagesStore.update((prev) => ({
|
||||
// ...prev,
|
||||
// subtitles: subtitles?.srclang || ''
|
||||
// }));
|
||||
|
||||
// if (subtitleInfo) {
|
||||
// if (subtitles)
|
||||
// subtitleInfo = {
|
||||
// ...subtitleInfo,
|
||||
// subtitles
|
||||
// };
|
||||
// else
|
||||
// subtitleInfo = {
|
||||
// ...subtitleInfo,
|
||||
// subtitles: undefined
|
||||
// };
|
||||
// }
|
||||
// };
|
||||
|
||||
// subtitleInfo = {
|
||||
// subtitles,
|
||||
// availableSubtitles,
|
||||
// selectSubtitles
|
||||
// };
|
||||
|
||||
// playbackInfo = {
|
||||
// audioStreamIndex: audioStreamIndex ?? mediaSource?.DefaultAudioStreamIndex ?? -1,
|
||||
// audioTracks:
|
||||
// mediaSource?.MediaStreams?.filter((s) => s.Type === 'Audio').map((s) => ({
|
||||
// index: s.Index || -1,
|
||||
// language: s.Language || ''
|
||||
// })) || [],
|
||||
// selectAudioTrack: (index: number) =>
|
||||
// loadPlaybackInfo({
|
||||
// ...options,
|
||||
// audioStreamIndex: index,
|
||||
// playbackPosition: progressTime * 10_000_000
|
||||
// }),
|
||||
// directPlay,
|
||||
// playbackUrl: $user?.settings.jellyfin.baseUrl + playbackUri,
|
||||
// backdrop: item?.BackdropImageTags?.length
|
||||
// ? `${$user?.settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
|
||||
// : '',
|
||||
// startTime:
|
||||
// (options.playbackPosition || 0) / 10_000_000 ||
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0) / 10_000_000 ||
|
||||
// undefined
|
||||
// };
|
||||
|
||||
// // if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);
|
||||
|
||||
// if (reportProgressInterval) clearInterval(reportProgressInterval);
|
||||
// // reportProgressInterval = setInterval(() => {
|
||||
// // if (video?.readyState === 4 && progressTime > 0 && sessionId && id)
|
||||
// // reportProgress(id, sessionId, paused, progressTime);
|
||||
// // }, 10_000);
|
||||
// }
|
||||
|
||||
// loadPlaybackInfo();
|
||||
|
||||
onDestroy(() => {
|
||||
if (reportProgressInterval) clearInterval(reportProgressInterval);
|
||||
// if (id && sessionId && progressTime) reportPlaybackStopped(id, sessionId, progressTime);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal class="bg-black">
|
||||
<VideoPlayer
|
||||
{playbackInfo}
|
||||
modalHidden={$modalStackTop?.id !== modalId}
|
||||
{title}
|
||||
{subtitle}
|
||||
bind:paused
|
||||
bind:progressTime
|
||||
bind:video
|
||||
bind:subtitleInfo
|
||||
/>
|
||||
</Modal>
|
||||
@@ -52,6 +52,7 @@
|
||||
|
||||
async function handleRemovePlugin() {
|
||||
await reiverrApiNew.users.deleteSource(plugin, get(user)?.id || '');
|
||||
await user.refreshUser();
|
||||
modalStack.close(modalId);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
<Button
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
jellyfinItem.Id && playerState.streamJellyfinId(jellyfinItem.Id)}
|
||||
jellyfinItem.Id && playerState.streamMovie(id)}
|
||||
>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
|
||||
19
src/lib/stores/sources.store.ts
Normal file
19
src/lib/stores/sources.store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { getReiverrApiNew, reiverrApi, type ReiverrUser } from '../apis/reiverr/reiverr-api';
|
||||
import axios from 'axios';
|
||||
import type { operations } from '../apis/reiverr/reiverr.generated';
|
||||
import { type Session, sessions } from './session.store';
|
||||
import { user } from './user.store';
|
||||
|
||||
function useSources() {
|
||||
const availableSources = derived(user, (user) =>
|
||||
user?.mediaSources?.filter((s) => s.enabled)?.map((s) => s.id)
|
||||
);
|
||||
|
||||
return {
|
||||
subscribe: availableSources.subscribe,
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
export const sources = useSources();
|
||||
@@ -4,6 +4,8 @@ import axios from 'axios';
|
||||
import type { operations } from '../apis/reiverr/reiverr.generated';
|
||||
import { type Session, sessions } from './session.store';
|
||||
|
||||
export let reiverrApiNew: ReturnType<typeof getReiverrApiNew>;
|
||||
|
||||
function useUser() {
|
||||
const activeSession = derived(sessions, (sessions) => sessions.activeSession);
|
||||
|
||||
@@ -11,6 +13,26 @@ function useUser() {
|
||||
|
||||
let lastActiveSession: Session | undefined;
|
||||
activeSession.subscribe(async (activeSession) => {
|
||||
refreshUser(activeSession);
|
||||
reiverrApiNew = getReiverrApiNew();
|
||||
});
|
||||
|
||||
async function updateUser(updateFn: (user: ReiverrUser) => ReiverrUser) {
|
||||
const user = get(userStore);
|
||||
|
||||
if (!user) return;
|
||||
|
||||
const updated = updateFn(user);
|
||||
const { user: update, error } = await reiverrApi.updateUser(updated.id, updated);
|
||||
|
||||
if (update) {
|
||||
userStore.set(update);
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
async function refreshUser(activeSession = get(sessions)?.activeSession) {
|
||||
if (!activeSession) {
|
||||
userStore.set(null);
|
||||
return;
|
||||
@@ -30,29 +52,13 @@ function useUser() {
|
||||
.catch(() => null);
|
||||
|
||||
if (lastActiveSession === activeSession) userStore.set(user);
|
||||
reiverrApiNew = getReiverrApiNew();
|
||||
});
|
||||
|
||||
async function updateUser(updateFn: (user: ReiverrUser) => ReiverrUser) {
|
||||
const user = get(userStore);
|
||||
|
||||
if (!user) return;
|
||||
|
||||
const updated = updateFn(user);
|
||||
const { user: update, error } = await reiverrApi.updateUser(updated.id, updated);
|
||||
|
||||
if (update) {
|
||||
userStore.set(update);
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: userStore.subscribe,
|
||||
updateUser
|
||||
updateUser,
|
||||
refreshUser
|
||||
};
|
||||
}
|
||||
|
||||
export const user = useUser();
|
||||
export let reiverrApiNew: ReturnType<typeof getReiverrApiNew>;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import scrollbarHide from 'tailwind-scrollbar-hide'
|
||||
|
||||
/**
|
||||
* https://huemint.com/website-monochrome/#palette=353633-fbfdff
|
||||
* https://huemint.com/website-monochrome/#palette=161718-dfd1a3 Very Nice
|
||||
@@ -60,5 +62,5 @@ export default {
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true
|
||||
},
|
||||
plugins: [require('tailwind-scrollbar-hide')]
|
||||
plugins: [scrollbarHide]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user