mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-23 01:05:13 +02:00
feat: Improved movie playback and listing stream options
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
91
backend/plugins/jellyfin.plugin/src/utils.ts
Normal file
91
backend/plugins/jellyfin.plugin/src/utils.ts
Normal 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'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
31
backend/plugins/plugin-types.d.ts
vendored
31
backend/plugins/plugin-types.d.ts
vendored
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
70
src/lib/pages/MoviePage/MovieStreams.MoviePage.svelte
Normal file
70
src/lib/pages/MoviePage/MovieStreams.MoviePage.svelte
Normal 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>
|
||||||
67
src/lib/pages/MoviePage/StreamDetailsDialog.MoviePage.svelte
Normal file
67
src/lib/pages/MoviePage/StreamDetailsDialog.MoviePage.svelte
Normal 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>
|
||||||
@@ -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
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user