feat: Experimental movie playback through plugins

This commit is contained in:
Aleksi Lassila
2024-12-07 14:30:25 +02:00
parent d3818903b3
commit 96d52299b0
22 changed files with 1960 additions and 112 deletions

View File

@@ -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}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}

View 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[];
}

View File

@@ -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}"`,
},
});

View File

@@ -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;
}

View File

@@ -19,7 +19,7 @@ export class SourcePluginsService {
);
}
async getLoadedPlugins(): Promise<Record<string, SourcePlugin>> {
async getPlugins(): Promise<Record<string, SourcePlugin>> {
return this.plugins;
}

View File

@@ -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;
}
}

View File

@@ -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
View File

@@ -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",

View File

@@ -94,4 +94,4 @@
}
]
}
}
}

View File

@@ -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
})

View File

@@ -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]"

View 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}
/>

View File

@@ -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

View 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>

View File

@@ -52,6 +52,7 @@
async function handleRemovePlugin() {
await reiverrApiNew.users.deleteSource(plugin, get(user)?.id || '');
await user.refreshUser();
modalStack.close(modalId);
}
</script>

View File

@@ -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" />

View 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();

View File

@@ -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>;

View File

@@ -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]
};