feat: Improved movie playback and listing stream options

This commit is contained in:
Aleksi Lassila
2024-12-10 03:32:51 +02:00
parent 96d52299b0
commit 1f7f74a8a7
13 changed files with 558 additions and 188 deletions

View File

@@ -13,7 +13,14 @@ import {
Subtitles, Subtitles,
UserContext, UserContext,
VideoStream, VideoStream,
VideoStreamCandidate,
} from 'plugins/plugin-types'; } from 'plugins/plugin-types';
import {
bitrateQualities,
formatSize,
formatTicksToTime,
getClosestBitrate,
} from './utils';
interface JellyfinSettings extends PluginSettings { interface JellyfinSettings extends PluginSettings {
apiKey: string; apiKey: string;
@@ -27,68 +34,14 @@ interface JellyfinUserContext extends UserContext {
const JELLYFIN_DEVICE_ID = 'Reiverr Client'; 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() @Injectable()
export default class JellyfinPlugin implements SourcePlugin { export default class JellyfinPlugin implements SourcePlugin {
name: string = 'jellyfin'; name: string = 'jellyfin';
private getProxyUrl(tmdbId: string) {
return `/api/movies/${tmdbId}/sources/${this.name}/stream/proxy`;
}
validateSettings: (settings: JellyfinSettings) => Promise<{ validateSettings: (settings: JellyfinSettings) => Promise<{
isValid: boolean; isValid: boolean;
errors: Record<string, string>; errors: Record<string, string>;
@@ -213,8 +166,25 @@ export default class JellyfinPlugin implements SourcePlugin {
.then((res) => res.data.Items ?? []); .then((res) => res.data.Items ?? []);
} }
async getMovieStreams(
tmdbId: string,
userContext: JellyfinUserContext,
config: PlaybackConfig = {
audioStreamIndex: undefined,
bitrate: undefined,
progress: undefined,
defaultLanguage: undefined,
deviceProfile: undefined,
},
): Promise<VideoStreamCandidate[]> {
return this.getMovieStream(tmdbId, '', userContext, config).then(
(stream) => [stream],
);
}
async getMovieStream( async getMovieStream(
tmdbId: string, tmdbId: string,
key: string,
userContext: JellyfinUserContext, userContext: JellyfinUserContext,
config: PlaybackConfig = { config: PlaybackConfig = {
audioStreamIndex: undefined, audioStreamIndex: undefined,
@@ -226,7 +196,7 @@ export default class JellyfinPlugin implements SourcePlugin {
): Promise<VideoStream> { ): Promise<VideoStream> {
const context = new PluginContext(userContext.settings, userContext.token); const context = new PluginContext(userContext.settings, userContext.token);
const items = await this.getLibraryItems(context); const items = await this.getLibraryItems(context);
const proxyUrl = `/api/movies/${tmdbId}/sources/${this.name}/stream`; const proxyUrl = this.getProxyUrl(tmdbId);
const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId); const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId);
@@ -288,22 +258,20 @@ export default class JellyfinPlugin implements SourcePlugin {
index: s.Index, index: s.Index,
})) ?? []; })) ?? [];
const qualities = [ const qualities: VideoStream['qualities'] = [
...bitrateQualities, ...bitrateQualities,
{ {
bitrate: mediasSource.Bitrate, bitrate: mediasSource.Bitrate,
label: 'Original', label: 'Original',
codec: undefined, codec: undefined,
original: true,
}, },
].map((q, i) => ({ ].map((q, i) => ({
...q, ...q,
index: i, index: i,
})); }));
const bitrate = Math.min( const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate);
maxStreamingBitrate,
movie.MediaSources[0].Bitrate,
);
const subtitles: Subtitles[] = mediasSource.MediaStreams.filter( const subtitles: Subtitles[] = mediasSource.MediaStreams.filter(
(s) => s.Type === 'Subtitle' && s.DeliveryUrl, (s) => s.Type === 'Subtitle' && s.DeliveryUrl,
@@ -315,6 +283,32 @@ export default class JellyfinPlugin implements SourcePlugin {
})); }));
return { return {
key: '',
title: movie.Name,
properties: [
{
label: 'Video',
value: mediasSource.Bitrate || 0,
formatted:
mediasSource.MediaStreams.find((s) => s.Type === 'Video')
?.DisplayTitle || 'Unknown',
},
{
label: 'Size',
value: mediasSource.Size,
formatted: formatSize(mediasSource.Size),
},
{
label: 'Filename',
value: mediasSource.Name,
formatted: undefined,
},
{
label: 'Runtime',
value: mediasSource.RunTimeTicks,
formatted: formatTicksToTime(mediasSource.RunTimeTicks),
},
],
audioStreamIndex: audioStreamIndex:
config.audioStreamIndex ?? config.audioStreamIndex ??
mediasSource?.DefaultAudioStreamIndex ?? mediasSource?.DefaultAudioStreamIndex ??
@@ -322,7 +316,7 @@ export default class JellyfinPlugin implements SourcePlugin {
audioStreams, audioStreams,
progress: config.progress ?? 0, progress: config.progress ?? 0,
qualities, qualities,
quality: getClosestBitrate(qualities, bitrate).index, qualityIndex: getClosestBitrate(qualities, bitrate).index,
subtitles, subtitles,
uri: playbackUri, uri: playbackUri,
directPlay: directPlay:

View File

@@ -0,0 +1,91 @@
export function formatSize(size: number) {
const gbs = size / 1024 / 1024 / 1024;
const mbs = size / 1024 / 1024;
if (gbs >= 1) {
return `${gbs.toFixed(2)} GB`;
} else {
return `${mbs.toFixed(2)} MB`;
}
}
export const bitrateQualities = [
{
label: '4K - 120 Mbps',
bitrate: 120000000,
codec: undefined,
original: false,
},
{
label: '4K - 80 Mbps',
bitrate: 80000000,
codec: undefined,
original: false,
},
{
label: '1080p - 40 Mbps',
bitrate: 40000000,
codec: undefined,
original: false,
},
{
label: '1080p - 10 Mbps',
bitrate: 10000000,
codec: undefined,
original: false,
},
{
label: '720p - 8 Mbps',
bitrate: 8000000,
codec: undefined,
original: false,
},
{
label: '720p - 4 Mbps',
bitrate: 4000000,
codec: undefined,
original: false,
},
{
label: '480p - 3 Mbps',
bitrate: 3000000,
codec: undefined,
original: false,
},
{
label: '480p - 720 Kbps',
bitrate: 720000,
codec: undefined,
original: false,
},
{
label: '360p - 420 Kbps',
bitrate: 420000,
codec: undefined,
original: false,
},
];
export function getClosestBitrate(qualities, bitrate) {
return qualities.reduce(
(prev, curr) =>
Math.abs(curr.bitrate - bitrate) < Math.abs(prev.bitrate - bitrate)
? curr
: prev,
qualities[0],
);
}
export function formatTicksToTime(ticks: number) {
return formatMinutesToTime(ticks / 10_000_000 / 60);
}
export function formatMinutesToTime(minutes: number) {
const days = Math.floor(minutes / 60 / 24);
const hours = Math.floor((minutes / 60) % 24);
const minutesLeft = Math.floor(minutes % 60);
return `${days > 0 ? days + 'd ' : ''}${hours > 0 ? hours + 'h ' : ''}${
days > 0 ? '' : minutesLeft + 'min'
}`;
}

View File

@@ -40,6 +40,7 @@ export type Quality = {
bitrate: number; bitrate: number;
label: string; label: string;
codec: string | undefined; codec: string | undefined;
original: boolean;
}; };
export type Subtitles = { export type Subtitles = {
@@ -49,14 +50,26 @@ export type Subtitles = {
codec: string | undefined; codec: string | undefined;
}; };
export type VideoStream = { export type VideoStreamProperty = {
label: string;
value: string | number;
formatted: string | undefined;
};
export type VideoStreamCandidate = {
key: string;
title: string;
properties: VideoStreamProperty[];
};
export type VideoStream = VideoStreamCandidate & {
uri: string; uri: string;
directPlay: boolean; directPlay: boolean;
progress: number; progress: number;
audioStreams: AudioStream[]; audioStreams: AudioStream[];
audioStreamIndex: number; audioStreamIndex: number;
qualities: Quality[]; qualities: Quality[];
quality: number; qualityIndex: number;
subtitles: Subtitles[]; subtitles: Subtitles[];
}; };
@@ -93,10 +106,24 @@ export interface SourcePlugin {
getMovieStream: ( getMovieStream: (
tmdbId: string, tmdbId: string,
key: string,
context: UserContext, context: UserContext,
config?: PlaybackConfig, config?: PlaybackConfig,
) => Promise<VideoStream>; ) => Promise<VideoStream>;
getMovieStreams: (
tmdbId: string,
context: UserContext,
config?: PlaybackConfig,
) => Promise<VideoStreamCandidate[]>;
getMovieStream: (
tmdbId: string,
context: UserContext,
key: string,
config?: PlaybackConfig,
) => Promise<VideoStream>;
getEpisodeStream: ( getEpisodeStream: (
tmdbId: string, tmdbId: string,
season: number, season: number,

View File

@@ -21,12 +21,11 @@ import { Request, Response } from 'express';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { User } from 'src/users/user.entity'; import { User } from 'src/users/user.entity';
import { UserSourcesService } from 'src/users/user-sources/user-sources.service'; import { UserSourcesService } from 'src/users/user-sources/user-sources.service';
import { PluginSettingsTemplate } from 'plugins/plugin-types';
import { import {
PlaybackConfigDto, PlaybackConfigDto,
PluginSettingsDto, PluginSettingsDto,
PluginSettingsTemplateDto, PluginSettingsTemplateDto,
SourceListDto as VideoStreamListDto, VideoStreamListDto,
ValidationResponsekDto as ValidationResponseDto, ValidationResponsekDto as ValidationResponseDto,
VideoStreamDto, VideoStreamDto,
} from './source-plugins.dto'; } from './source-plugins.dto';
@@ -97,48 +96,66 @@ export class SourcesController {
} }
@ApiTags('movies') @ApiTags('movies')
@Get('movies/:tmdbId/sources') @Get('movies/:tmdbId/sources/:sourceId/streams')
@ApiOkResponse({ @ApiOkResponse({
description: 'Movie sources', description: 'Movie sources',
type: VideoStreamListDto, type: VideoStreamListDto,
}) })
async getMovieSources( async getMovieStreams(
@Param('tmdbId') tmdbId: string, @Param('tmdbId') tmdbId: string,
@Param('sourceId') sourceId: string,
@GetUser() user: User, @GetUser() user: User,
@GetAuthToken() token: string, @GetAuthToken() token: string,
): Promise<VideoStreamListDto> { ): Promise<VideoStreamListDto> {
if (!user) { const plugin = this.sourcesService.getPlugin(sourceId);
throw new UnauthorizedException();
if (!plugin) {
throw new NotFoundException('Plugin not found');
} }
const plugins = await this.sourcesService.getPlugins(); const settings = this.userSourcesService.getSourceSettings(user, sourceId);
const sources: VideoStreamListDto['sources'] = {};
for (const pluginId in plugins) { if (!settings) {
const plugin = plugins[pluginId]; throw new BadRequestException('Source configuration not found');
}
if (!plugin) continue; const streams = await plugin.getMovieStreams(tmdbId, {
const settings = this.userSourcesService.getSourceSettings(
user,
pluginId,
);
if (!settings) continue;
const videoStream = await plugin.getMovieStream(tmdbId, {
settings, settings,
token, token,
}); });
if (!videoStream) continue;
sources[pluginId] = videoStream;
}
return { return {
sources, streams,
}; };
// const plugins = await this.sourcesService.getPlugins();
// const streams: VideoStreamListDto['streams'] = [];
// 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.getMovieStreams(tmdbId, {
// settings,
// token,
// });
// if (!videoStream) continue;
// streams[pluginId] = videoStream;
// }
// return {
// streams,
// };
} }
@ApiTags('movies') @ApiTags('movies')
@@ -148,14 +165,17 @@ export class SourcesController {
type: VideoStreamDto, type: VideoStreamDto,
}) })
async getMovieStream( async getMovieStream(
@Param('sourceId') sourceId: string,
@Param('tmdbId') tmdbId: string, @Param('tmdbId') tmdbId: string,
@Param('sourceId') sourceId: string,
@Query('key') key: string,
@GetUser() user: User, @GetUser() user: User,
@GetAuthToken() token: string, @GetAuthToken() token: string,
@Body() config: PlaybackConfigDto, @Body() config: PlaybackConfigDto,
): Promise<VideoStreamDto> { ): Promise<VideoStreamDto> {
if (!user) { const plugin = this.sourcesService.getPlugin(sourceId);
throw new UnauthorizedException();
if (!plugin) {
throw new NotFoundException('Plugin not found');
} }
const settings = this.userSourcesService.getSourceSettings(user, sourceId); const settings = this.userSourcesService.getSourceSettings(user, sourceId);
@@ -164,8 +184,9 @@ export class SourcesController {
throw new BadRequestException('Source configuration not found'); throw new BadRequestException('Source configuration not found');
} }
return this.sourcesService.getPlugin(sourceId)?.getMovieStream( return plugin.getMovieStream(
tmdbId, tmdbId,
key || '',
{ {
settings, settings,
token, token,
@@ -175,7 +196,7 @@ export class SourcesController {
} }
@ApiTags('movies') @ApiTags('movies')
@All('movies/:tmdbId/sources/:sourceId/stream/*') @All('movies/:tmdbId/sources/:sourceId/stream/proxy/*')
async getMovieStreamProxy( async getMovieStreamProxy(
@Param() params: any, @Param() params: any,
@Req() req: Request, @Req() req: Request,

View File

@@ -14,6 +14,8 @@ import {
Subtitles, Subtitles,
ValidationResponse, ValidationResponse,
VideoStream, VideoStream,
VideoStreamCandidate,
VideoStreamProperty,
} from 'plugins/plugin-types'; } from 'plugins/plugin-types';
import { DeviceProfileDto } from './device-profile.dto'; import { DeviceProfileDto } from './device-profile.dto';
@@ -98,23 +100,6 @@ export class ValidationResponsekDto implements ValidationResponse {
replace: Record<string, any>; 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 { export class AudioStreamDto implements AudioStream {
@ApiProperty() @ApiProperty()
index: number; index: number;
@@ -141,6 +126,9 @@ export class QualityDto implements Quality {
@ApiProperty({ required: false }) @ApiProperty({ required: false })
codec: string | undefined; codec: string | undefined;
@ApiProperty()
original: boolean;
} }
export class SubtitlesDto implements Subtitles { export class SubtitlesDto implements Subtitles {
@@ -157,7 +145,34 @@ export class SubtitlesDto implements Subtitles {
codec: string | undefined; codec: string | undefined;
} }
export class VideoStreamDto implements VideoStream { export class VideoStreamPropertyDto implements VideoStreamProperty {
@ApiProperty()
label: string;
@ApiProperty({
oneOf: [{ type: 'string' }, { type: 'number' }],
})
value: string | number;
@ApiProperty({ required: false })
formatted: string | undefined;
}
export class VideoStreamCandidateDto implements VideoStreamCandidate {
@ApiProperty()
key: string;
@ApiProperty()
title: string;
@ApiProperty({ type: [VideoStreamPropertyDto] })
properties: VideoStreamPropertyDto[];
}
export class VideoStreamDto
extends VideoStreamCandidateDto
implements VideoStream
{
@ApiProperty() @ApiProperty()
uri: string; uri: string;
@@ -177,7 +192,7 @@ export class VideoStreamDto implements VideoStream {
qualities: QualityDto[]; qualities: QualityDto[];
@ApiProperty() @ApiProperty()
quality: number; qualityIndex: number;
@ApiProperty({ type: [SubtitlesDto] }) @ApiProperty({ type: [SubtitlesDto] })
subtitles: SubtitlesDto[]; subtitles: SubtitlesDto[];
@@ -203,3 +218,10 @@ export class PlaybackConfigDto implements PlaybackConfig {
@ApiPropertyOptional({ example: 'en', required: false }) @ApiPropertyOptional({ example: 'en', required: false })
defaultLanguage: string | undefined; defaultLanguage: string | undefined;
} }
export class VideoStreamListDto {
@ApiProperty({
type: [VideoStreamCandidateDto],
})
streams: VideoStreamCandidateDto[];
}

View File

@@ -120,9 +120,20 @@ export interface ValidationResponsekDto {
replace: Record<string, any>; replace: Record<string, any>;
} }
export interface SourceListDto { export interface VideoStreamPropertyDto {
/** @example {"source1":{"uri":"/path/to/stream"},"source2":{"uri":"/path/to/other/stream"}} */ label: string;
sources: Record<string, any>; value: string | number;
formatted?: string;
}
export interface VideoStreamCandidateDto {
key: string;
title: string;
properties: VideoStreamPropertyDto[];
}
export interface VideoStreamListDto {
streams: VideoStreamCandidateDto[];
} }
export interface DirectPlayProfileDto { export interface DirectPlayProfileDto {
@@ -347,6 +358,7 @@ export interface QualityDto {
bitrate: number; bitrate: number;
label: string; label: string;
codec?: string; codec?: string;
original: boolean;
} }
export interface SubtitlesDto { export interface SubtitlesDto {
@@ -357,13 +369,16 @@ export interface SubtitlesDto {
} }
export interface VideoStreamDto { export interface VideoStreamDto {
key: string;
title: string;
properties: VideoStreamPropertyDto[];
uri: string; uri: string;
directPlay: boolean; directPlay: boolean;
progress: number; progress: number;
audioStreams: AudioStreamDto[]; audioStreams: AudioStreamDto[];
audioStreamIndex: number; audioStreamIndex: number;
qualities: QualityDto[]; qualities: QualityDto[];
quality: number; qualityIndex: number;
subtitles: SubtitlesDto[]; subtitles: SubtitlesDto[];
} }
@@ -786,12 +801,12 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* No description * No description
* *
* @tags movies * @tags movies
* @name GetMovieSources * @name GetMovieStreams
* @request GET:/api/movies/{tmdbId}/sources * @request GET:/api/movies/{tmdbId}/sources/{sourceId}/streams
*/ */
getMovieSources: (tmdbId: string, params: RequestParams = {}) => getMovieStreams: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<SourceListDto, any>({ this.request<VideoStreamListDto, any>({
path: `/api/movies/${tmdbId}/sources`, path: `/api/movies/${tmdbId}/sources/${sourceId}/streams`,
method: 'GET', method: 'GET',
format: 'json', format: 'json',
...params ...params
@@ -805,14 +820,18 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* @request POST:/api/movies/{tmdbId}/sources/{sourceId}/stream * @request POST:/api/movies/{tmdbId}/sources/{sourceId}/stream
*/ */
getMovieStream: ( getMovieStream: (
sourceId: string,
tmdbId: string, tmdbId: string,
sourceId: string,
query: {
key: string;
},
data: PlaybackConfigDto, data: PlaybackConfigDto,
params: RequestParams = {} params: RequestParams = {}
) => ) =>
this.request<VideoStreamDto, any>({ this.request<VideoStreamDto, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream`,
method: 'POST', method: 'POST',
query: query,
body: data, body: data,
type: ContentType.Json, type: ContentType.Json,
format: 'json', format: 'json',
@@ -824,11 +843,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxyGet * @name GetMovieStreamProxyGet
* @request GET:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request GET:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxyGet: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxyGet: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'GET', method: 'GET',
...params ...params
}), }),
@@ -838,11 +857,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxyPost * @name GetMovieStreamProxyPost
* @request POST:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request POST:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxyPost: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxyPost: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'POST', method: 'POST',
...params ...params
}), }),
@@ -852,11 +871,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxyPut * @name GetMovieStreamProxyPut
* @request PUT:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request PUT:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxyPut: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxyPut: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'PUT', method: 'PUT',
...params ...params
}), }),
@@ -866,11 +885,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxyDelete * @name GetMovieStreamProxyDelete
* @request DELETE:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request DELETE:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxyDelete: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxyDelete: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'DELETE', method: 'DELETE',
...params ...params
}), }),
@@ -880,11 +899,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxyPatch * @name GetMovieStreamProxyPatch
* @request PATCH:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request PATCH:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxyPatch: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxyPatch: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'PATCH', method: 'PATCH',
...params ...params
}), }),
@@ -894,11 +913,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxyOptions * @name GetMovieStreamProxyOptions
* @request OPTIONS:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request OPTIONS:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxyOptions: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxyOptions: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'OPTIONS', method: 'OPTIONS',
...params ...params
}), }),
@@ -908,11 +927,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxyHead * @name GetMovieStreamProxyHead
* @request HEAD:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request HEAD:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxyHead: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxyHead: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'HEAD', method: 'HEAD',
...params ...params
}), }),
@@ -922,11 +941,11 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
* *
* @tags movies * @tags movies
* @name GetMovieStreamProxySearch * @name GetMovieStreamProxySearch
* @request SEARCH:/api/movies/{tmdbId}/sources/{sourceId}/stream/* * @request SEARCH:/api/movies/{tmdbId}/sources/{sourceId}/stream/proxy/*
*/ */
getMovieStreamProxySearch: (tmdbId: string, sourceId: string, params: RequestParams = {}) => getMovieStreamProxySearch: (tmdbId: string, sourceId: string, params: RequestParams = {}) =>
this.request<void, any>({ this.request<void, any>({
path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`,
method: 'SEARCH', method: 'SEARCH',
...params ...params
}) })

View File

@@ -5,7 +5,7 @@ import SeriesPage from '../SeriesPage/SeriesPage.svelte';
import EpisodePage from '../../pages/EpisodePage.svelte'; import EpisodePage from '../../pages/EpisodePage.svelte';
import { modalStack } from '../Modal/modal.store'; import { modalStack } from '../Modal/modal.store';
import MoviesHomePage from '../../pages/MoviesHomePage.svelte'; import MoviesHomePage from '../../pages/MoviesHomePage.svelte';
import MoviePage from '../../pages/MoviePage.svelte'; import MoviePage from '../../pages/MoviePage/MoviePage.svelte';
import LibraryPage from '../../pages/LibraryPage.svelte'; import LibraryPage from '../../pages/LibraryPage.svelte';
import SearchPage from '../../pages/SearchPage.svelte'; import SearchPage from '../../pages/SearchPage.svelte';
import PageNotFound from '../../pages/PageNotFound.svelte'; import PageNotFound from '../../pages/PageNotFound.svelte';

View File

@@ -11,6 +11,7 @@
export let tmdbId: string; export let tmdbId: string;
export let sourceId: string; export let sourceId: string;
export let key: string = '';
export let modalId: symbol; export let modalId: symbol;
export let hidden: boolean = false; export let hidden: boolean = false;
@@ -32,12 +33,19 @@
const refreshVideoStream = async (audioStreamIndex = 0) => { const refreshVideoStream = async (audioStreamIndex = 0) => {
console.log('called2'); console.log('called2');
videoStreamP = reiverrApiNew.movies videoStreamP = reiverrApiNew.movies
.getMovieStream(sourceId, tmdbId, { .getMovieStream(
tmdbId,
sourceId,
{
key
},
{
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
progress: 0, progress: 0,
audioStreamIndex, audioStreamIndex,
deviceProfile: getDeviceProfile() as any deviceProfile: getDeviceProfile() as any
}) }
)
.then((r) => r.data) .then((r) => r.data)
.then((d) => ({ .then((d) => ({
...d, ...d,

View File

@@ -1,4 +1,4 @@
import { writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { modalStack } from '../Modal/modal.store'; import { modalStack } from '../Modal/modal.store';
import { jellyfinItemsStore } from '../../stores/data.store'; import { jellyfinItemsStore } from '../../stores/data.store';
import JellyfinVideoPlayerModal from './JellyfinVideoPlayerModal.svelte'; import JellyfinVideoPlayerModal from './JellyfinVideoPlayerModal.svelte';
@@ -49,10 +49,17 @@ export type PlayerStateValue = typeof initialValue;
function usePlayerState() { function usePlayerState() {
const store = writable<PlayerStateValue>(initialValue); const store = writable<PlayerStateValue>(initialValue);
async function streamMovie(tmdbId: string, sourceId: string = '') { async function streamMovie(tmdbId: string, sourceId: string = '', key: string = '') {
if (!sourceId) { if (!sourceId) {
const sources = await reiverrApiNew.movies.getMovieSources(tmdbId).then((r) => r.data); const streams = await Promise.all(
sourceId = Object.keys(sources.sources)[0] || ''; get(sources).map((s) =>
reiverrApiNew.movies
.getMovieStreams(tmdbId, s.id)
.then((r) => ({ source: s, streams: r.data.streams }))
)
);
sourceId = streams?.[0]?.source.id || '';
key = streams?.[0]?.streams?.[0]?.key || '';
} }
if (!sourceId) { if (!sourceId) {
@@ -63,7 +70,8 @@ function usePlayerState() {
store.set({ visible: true, jellyfinId: tmdbId, sourceId }); store.set({ visible: true, jellyfinId: tmdbId, sourceId });
modalStack.create(MovieVideoPlayerModal, { modalStack.create(MovieVideoPlayerModal, {
tmdbId, tmdbId,
sourceId sourceId,
key
}); });
} }

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import Container from '../../Container.svelte'; import Container from '../../../Container.svelte';
import HeroCarousel from '../components/HeroCarousel/HeroCarousel.svelte'; import HeroCarousel from '../../components/HeroCarousel/HeroCarousel.svelte';
import { tmdbApi } from '../apis/tmdb/tmdb-api'; import { tmdbApi } from '../../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../constants'; import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Cross1, Cross1,
@@ -14,24 +14,34 @@
Plus, Plus,
Trash Trash
} from 'radix-icons-svelte'; } from 'radix-icons-svelte';
import Button from '../components/Button.svelte'; import Button from '../../components/Button.svelte';
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api'; import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
import { type MovieDownload, type MovieFileResource, radarrApi } from '../apis/radarr/radarr-api'; import {
import { useActionRequests, useRequest } from '../stores/data.store'; type MovieDownload,
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte'; type MovieFileResource,
import { createModal, modalStack } from '../components/Modal/modal.store'; radarrApi
import { playerState } from '../components/VideoPlayer/VideoPlayer'; } from '../../apis/radarr/radarr-api';
import { scrollIntoView } from '../selectable'; import { useActionRequests, useRequest } from '../../stores/data.store';
import Carousel from '../components/Carousel/Carousel.svelte'; import DetachedPage from '../../components/DetachedPage/DetachedPage.svelte';
import TmdbPersonCard from '../components/PersonCard/TmdbPersonCard.svelte'; import { createModal, modalStack } from '../../components/Modal/modal.store';
import TmdbCard from '../components/Card/TmdbCard.svelte'; import { playerState } from '../../components/VideoPlayer/VideoPlayer';
import MovieMediaManagerModal from '../components/MediaManagerModal/RadarrMediaManagerModal.svelte'; import { scrollIntoView } from '../../selectable';
import MMAddToRadarrDialog from '../components/MediaManagerModal/MMAddToRadarrDialog.svelte'; import Carousel from '../../components/Carousel/Carousel.svelte';
import FileDetailsDialog from '../components/SeriesPage/FileDetailsDialog.svelte'; import TmdbPersonCard from '../../components/PersonCard/TmdbPersonCard.svelte';
import DownloadDetailsDialog from '../components/SeriesPage/DownloadDetailsDialog.svelte'; import TmdbCard from '../../components/Card/TmdbCard.svelte';
import { capitalize, formatSize } from '../utils'; import MovieMediaManagerModal from '../../components/MediaManagerModal/RadarrMediaManagerModal.svelte';
import ConfirmDialog from '../components/Dialog/ConfirmDialog.svelte'; import MMAddToRadarrDialog from '../../components/MediaManagerModal/MMAddToRadarrDialog.svelte';
import { TMDB_BACKDROP_SMALL } from '../constants.js'; import FileDetailsDialog from '../../components/SeriesPage/FileDetailsDialog.svelte';
import DownloadDetailsDialog from '../../components/SeriesPage/DownloadDetailsDialog.svelte';
import { capitalize, formatSize } from '../../utils';
import ConfirmDialog from '../../components/Dialog/ConfirmDialog.svelte';
import { TMDB_BACKDROP_SMALL } from '../../constants.js';
import { reiverrApiNew } from '../../stores/user.store';
import { sources } from '../../stores/sources.store';
import { get } from 'svelte/store';
import type { VideoStreamCandidateDto, MediaSource } from '../../apis/reiverr/reiverr.openapi';
import MovieStreams from './MovieStreams.MoviePage.svelte';
import StreamDetailsDialog from './StreamDetailsDialog.MoviePage.svelte';
export let id: string; export let id: string;
const tmdbId = Number(id); const tmdbId = Number(id);
@@ -42,6 +52,23 @@
(id: string) => jellyfinApi.getLibraryItemFromTmdbId(id), (id: string) => jellyfinApi.getLibraryItemFromTmdbId(id),
id id
); );
const streams = getStreams();
function getStreams(): Map<MediaSource, Promise<VideoStreamCandidateDto[]>> {
const out = new Map();
for (const source of get(sources)) {
out.set(
source,
reiverrApiNew.movies.getMovieStreams(id, source.id).then((r) => r.data?.streams ?? [])
);
}
console.log(out);
return out;
}
const { promise: radarrItemP, send: refreshRadarrItem } = useRequest( const { promise: radarrItemP, send: refreshRadarrItem } = useRequest(
radarrApi.getMovieByTmdbId, radarrApi.getMovieByTmdbId,
tmdbId tmdbId
@@ -110,6 +137,18 @@
.then(() => (radarrDownloads = getDownloads(radarrItem))) .then(() => (radarrDownloads = getDownloads(radarrItem)))
}); });
} }
async function createStreamDetailsDialog(source: MediaSource, stream: VideoStreamCandidateDto) {
const movie = await tmdbMovie;
modalStack.create(StreamDetailsDialog, {
stream,
// title: movie?.title || '',
// subtitle: file.relativePath || '',
backgroundUrl: TMDB_BACKDROP_SMALL + movie?.backdrop_path || '',
streamMovie: () => playerState.streamMovie(id, source.id, stream.key),
onDelete: () => (radarrFiles = getFiles(radarrItem))
});
}
</script> </script>
<DetachedPage let:handleGoBack let:registrar> <DetachedPage let:handleGoBack let:registrar>
@@ -173,8 +212,7 @@
{#if jellyfinItem} {#if jellyfinItem}
<Button <Button
class="mr-4" class="mr-4"
on:clickOrSelect={() => on:clickOrSelect={() => jellyfinItem.Id && playerState.streamMovie(id)}
jellyfinItem.Id && playerState.streamMovie(id)}
> >
Play Play
<Play size={19} slot="icon" /> <Play size={19} slot="icon" />
@@ -281,7 +319,12 @@
</div> </div>
</Container> </Container>
{/await} {/await}
{#await Promise.all([tmdbMovie, radarrFiles, radarrDownloads]) then [movie, files, downloads]}
{#if streams.size}
<MovieStreams {streams} {createStreamDetailsDialog} />
{/if}
<!-- {#await Promise.all([tmdbMovie, radarrFiles, radarrDownloads]) then [movie, files, downloads]}
{#if files?.length || downloads?.length} {#if files?.length || downloads?.length}
<Container <Container
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col" class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
@@ -390,6 +433,6 @@
</div> </div>
</Container> </Container>
{/if} {/if}
{/await} {/await} -->
</div> </div>
</DetachedPage> </DetachedPage>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import classNames from 'classnames';
import Container from '../../../Container.svelte';
import type { VideoStreamCandidateDto, MediaSource } from '../../apis/reiverr/reiverr.openapi';
import { scrollIntoView } from '../../selectable';
import { capitalize } from '../../utils';
import StreamDetailsDialog from './StreamDetailsDialog.MoviePage.svelte';
import { modalStack } from '../../components/Modal/modal.store';
export let streams: Map<MediaSource, Promise<VideoStreamCandidateDto[]>>;
export let createStreamDetailsDialog: (source: MediaSource, stream: VideoStreamCandidateDto) => void;
</script>
<Container
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
on:enter={scrollIntoView({ top: 32 })}
>
{#each [...streams.keys()] as source}
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">
{capitalize(source.id)}
</h1>
{#await streams.get(source)}
Loading...
{:then streams}
<Container
direction="grid"
gridCols={2}
class={classNames('grid gap-8', {
'grid-cols-1': (streams || []).length < 2,
'grid-cols-2': (streams || []).length >= 2
})}
>
{#each streams || [] as stream}
<Container
class={classNames(
'flex space-x-8 items-center text-zinc-300 font-medium relative overflow-hidden',
'px-8 py-4 border-2 border-transparent rounded-xl',
{
'bg-secondary-800 focus-within:bg-primary-700 focus-within:border-primary-500': true,
'hover:bg-primary-700 hover:border-primary-500 cursor-pointer': true
// 'bg-primary-700 focus-within:border-primary-500': selected,
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
}
)}
on:clickOrSelect={() => createStreamDetailsDialog(source, stream)}
on:enter={scrollIntoView({ vertical: 128 })}
focusOnClick
>
<div class="flex-1">
<h1 class="text-lg">
{stream.title}
</h1>
</div>
{#each stream.properties.slice(0, 2) as property}
<div>
{property.formatted ?? property.value}
</div>
{/each}
<!-- <div>
{file?.mediaInfo?.runTime}
</div>
<div>
{formatSize(file?.size || 0)}
</div> -->
</Container>
{/each}
</Container>
{/await}
{/each}
</Container>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
import Container from '../../../Container.svelte';
import { formatSize } from '../../utils';
import { Play, Trash } from 'radix-icons-svelte';
import type { FileResource } from '../../apis/combined-types';
import type { VideoStreamCandidateDto } from '../../apis/reiverr/reiverr.openapi';
import Dialog from '../../components/Dialog/Dialog.svelte';
import Button from '../../components/Button.svelte';
import { playerState } from '../../components/VideoPlayer/VideoPlayer';
export let stream: VideoStreamCandidateDto;
// export let file: FileResource;
// export let title = '';
// export let subtitle = '';
export let backgroundUrl: string;
export let streamMovie: () => Promise<any>;
export let onDelete: () => void;
async function handleDeleteFile() {
// return sonarrApi.deleteSonarrEpisode(file.id || -1).then(() => onDelete());
}
</script>
<Dialog class="flex flex-col relative">
{#if backgroundUrl}
<div
class="absolute inset-0 bg-cover bg-center h-52"
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
/>
{/if}
<div class="z-10">
{#if backgroundUrl}
<div class="h-24" />
{/if}
<h1 class="header2">{stream.title}</h1>
<h2 class="header1 mb-4">{stream.key}</h2>
<div
class="grid grid-cols-[1fr_auto] font-medium mb-16
[&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-2
[&>*:nth-child(odd):not(:nth-last-child(-n+2))]:border-b [&>*:nth-child(odd):not(:nth-last-child(-n+2))]:border-secondary-600
[&>*:nth-child(even):not(:nth-last-child(-n+2))]:border-b [&>*:nth-child(even):not(:nth-last-child(-n+2))]:border-secondary-600"
>
{#each stream.properties as property}
<span class="pr-8">{property.label}</span>
<span class="truncate" title={property.formatted ?? property.value.toString()}>
{property.formatted ?? property.value}
</span>
{/each}
<!-- <span class="border-b border-secondary-600">Runtime</span>
<span class="border-b border-secondary-600">{file.mediaInfo?.runTime}</span>
<span class="border-b border-secondary-600">Size on Disk</span>
<span class="border-b border-secondary-600">{formatSize(file.size || 0)}</span>
<span>Quality</span>
<span>{file.quality?.quality?.name}</span> -->
</div>
<Container class="flex flex-col space-y-4">
<Button type="secondary" icon={Play} action={streamMovie}>Play</Button>
<Button type="secondary" confirmDanger action={handleDeleteFile} disabled={true}>
<Trash size={19} slot="icon" />
Delete File
</Button>
</Container>
</div>
</Dialog>

View File

@@ -6,13 +6,13 @@ import { type Session, sessions } from './session.store';
import { user } from './user.store'; import { user } from './user.store';
function useSources() { function useSources() {
const availableSources = derived(user, (user) => const availableSources = derived(
user?.mediaSources?.filter((s) => s.enabled)?.map((s) => s.id) user,
(user) => user?.mediaSources?.filter((s) => s.enabled)?.map((s) => ({ ...s })) ?? []
); );
return { return {
subscribe: availableSources.subscribe, subscribe: availableSources.subscribe
}; };
} }