mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-26 18:55:12 +02:00
build: make file downloads persistent in docker, refactor built-in plugin workspaces
This commit is contained in:
11
backend/packages/jellyfin.plugin/src/generate-openapi.ts
Normal file
11
backend/packages/jellyfin.plugin/src/generate-openapi.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { generateApi } from 'swagger-typescript-api';
|
||||
|
||||
generateApi({
|
||||
name: 'jellyfin.openapi.ts',
|
||||
url: 'https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json',
|
||||
output: __dirname,
|
||||
// generateClient: true,
|
||||
// generateRouteTypes: false,
|
||||
// sortTypes: true,
|
||||
httpClientType: 'axios',
|
||||
});
|
||||
464
backend/packages/jellyfin.plugin/src/index.ts
Normal file
464
backend/packages/jellyfin.plugin/src/index.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import { BaseItemKind, ItemFields } from './jellyfin.openapi';
|
||||
import {
|
||||
EpisodeMetadata,
|
||||
IndexItem,
|
||||
MovieMetadata,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
PlaybackConfig,
|
||||
PluginProvider,
|
||||
SettingsManager,
|
||||
SourceProvider,
|
||||
SourceProviderError,
|
||||
Stream,
|
||||
StreamCandidate,
|
||||
Subtitles,
|
||||
UserContext,
|
||||
} from '@aleksilassila/reiverr-plugin';
|
||||
import { Readable } from 'stream';
|
||||
import {
|
||||
JellyfinSettings,
|
||||
JellyfinUserContext,
|
||||
PluginContext,
|
||||
} from './plugin-context';
|
||||
import { JellyfinSettingsManager } from './settings';
|
||||
import {
|
||||
bitrateQualities,
|
||||
formatSize,
|
||||
formatTicksToTime,
|
||||
getClosestBitrate,
|
||||
JELLYFIN_DEVICE_ID,
|
||||
} from './utils';
|
||||
|
||||
export default class JellyfinPluginProvider extends PluginProvider {
|
||||
getPlugins(): SourceProvider[] {
|
||||
return [new JellyfinProvider()];
|
||||
}
|
||||
}
|
||||
|
||||
class JellyfinProvider extends SourceProvider {
|
||||
name: string = 'jellyfin';
|
||||
|
||||
private getProxyUrl(sourceId: string) {
|
||||
return `/api/sources/${sourceId}/proxy`;
|
||||
}
|
||||
|
||||
settingsManager: SettingsManager = new JellyfinSettingsManager();
|
||||
|
||||
private async getLibraryItems(context: PluginContext) {
|
||||
return context.api.items
|
||||
.getItems({
|
||||
userId: context.settings.userId,
|
||||
// hasTmdbId: true,
|
||||
recursive: true,
|
||||
includeItemTypes: [
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.Series,
|
||||
BaseItemKind.Episode,
|
||||
],
|
||||
fields: [
|
||||
ItemFields.ProviderIds,
|
||||
ItemFields.Genres,
|
||||
ItemFields.DateLastMediaAdded,
|
||||
ItemFields.DateCreated,
|
||||
ItemFields.MediaSources,
|
||||
],
|
||||
})
|
||||
.then((res) => res.data.Items ?? []);
|
||||
}
|
||||
|
||||
getMovieCatalogue = async (
|
||||
userContext: UserContext,
|
||||
pagination: PaginationParams,
|
||||
): Promise<PaginatedResponse<IndexItem>> => {
|
||||
const items = (
|
||||
await this.getLibraryItems(
|
||||
new PluginContext(userContext.settings, userContext.token),
|
||||
)
|
||||
).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie');
|
||||
|
||||
const startIndex = (pagination.page - 1) * pagination.itemsPerPage;
|
||||
const endIndex = startIndex + pagination.itemsPerPage;
|
||||
|
||||
return {
|
||||
total: items.length,
|
||||
page: pagination.page,
|
||||
itemsPerPage: pagination.itemsPerPage,
|
||||
items: items.slice(startIndex, endIndex).map((item) => ({
|
||||
id: item.ProviderIds?.Tmdb,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
getMovieStreams = async (
|
||||
tmdbId: string,
|
||||
metadata: MovieMetadata,
|
||||
context: UserContext,
|
||||
config?: PlaybackConfig,
|
||||
): Promise<{ candidates: StreamCandidate[] }> => {
|
||||
return this.getMovieStream(tmdbId, metadata, '', context, config)
|
||||
.then((stream) => ({ candidates: [stream] }))
|
||||
.catch((e) => {
|
||||
if (e === SourceProviderError.StreamNotFound) {
|
||||
return { candidates: [] };
|
||||
} else throw e;
|
||||
});
|
||||
};
|
||||
|
||||
getEpisodeStreams = async (
|
||||
tmdbId: string,
|
||||
metadata: EpisodeMetadata,
|
||||
context: UserContext,
|
||||
config?: PlaybackConfig,
|
||||
): Promise<{ candidates: StreamCandidate[] }> => {
|
||||
return this.getEpisodeStream(tmdbId, metadata, '', context, config)
|
||||
.then((stream) => ({ candidates: [stream] }))
|
||||
.catch((e) => {
|
||||
if (e === SourceProviderError.StreamNotFound) {
|
||||
return { candidates: [] };
|
||||
} else throw e;
|
||||
});
|
||||
};
|
||||
|
||||
getMovieStream = async (
|
||||
tmdbId: string,
|
||||
metadata: MovieMetadata,
|
||||
key: string,
|
||||
userContext: UserContext,
|
||||
config?: PlaybackConfig,
|
||||
): Promise<Stream | undefined> => {
|
||||
const context = new PluginContext(userContext.settings, userContext.token);
|
||||
const items = await this.getLibraryItems(context);
|
||||
|
||||
const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId);
|
||||
|
||||
// console.log(items.map((item) => item))
|
||||
|
||||
if (!movie || !movie.MediaSources || movie.MediaSources.length === 0) {
|
||||
throw SourceProviderError.StreamNotFound;
|
||||
}
|
||||
|
||||
/*
|
||||
await jellyfinApi.getPlaybackInfo(
|
||||
id,
|
||||
getDeviceProfile(),
|
||||
options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
audioStreamIndex
|
||||
);
|
||||
*/
|
||||
|
||||
const startTimeTicks = movie.RunTimeTicks
|
||||
? Math.floor(movie.RunTimeTicks * config?.progress)
|
||||
: undefined;
|
||||
const maxStreamingBitrate = config?.bitrate || 0; //|| movie.MediaSources?.[0]?.Bitrate || 10000000
|
||||
|
||||
const playbackInfo = await context.api.items.getPostedPlaybackInfo(
|
||||
movie.Id,
|
||||
{
|
||||
DeviceProfile: config?.deviceProfile,
|
||||
},
|
||||
{
|
||||
userId: context.settings.userId,
|
||||
startTimeTicks: startTimeTicks || 0,
|
||||
...(maxStreamingBitrate ? { maxStreamingBitrate } : {}),
|
||||
autoOpenLiveStream: true,
|
||||
...(config?.audioStreamIndex
|
||||
? { audioStreamIndex: config?.audioStreamIndex }
|
||||
: {}),
|
||||
mediaSourceId: movie.Id,
|
||||
|
||||
// deviceId: JELLYFIN_DEVICE_ID,
|
||||
// mediaSourceId: movie.MediaSources[0].Id,
|
||||
// maxBitrate: 8000000,
|
||||
},
|
||||
);
|
||||
|
||||
const mediasSource = playbackInfo.data?.MediaSources?.[0];
|
||||
|
||||
const playbackUri =
|
||||
this.getProxyUrl(userContext.sourceId) +
|
||||
(mediasSource?.TranscodingUrl ||
|
||||
`/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${mediasSource?.ETag}`) +
|
||||
`&reiverr_token=${userContext.token}`;
|
||||
|
||||
const audioStreams: Stream['audioStreams'] =
|
||||
mediasSource?.MediaStreams.filter((s) => s.Type === 'Audio').map((s) => ({
|
||||
bitrate: s.BitRate,
|
||||
label: s.Language,
|
||||
codec: s.Codec,
|
||||
index: s.Index,
|
||||
})) ?? [];
|
||||
|
||||
const qualities: Stream['qualities'] = [
|
||||
...bitrateQualities,
|
||||
{
|
||||
bitrate: mediasSource.Bitrate,
|
||||
label: 'Original',
|
||||
codec: undefined,
|
||||
original: true,
|
||||
},
|
||||
].map((q, i) => ({
|
||||
...q,
|
||||
index: i,
|
||||
}));
|
||||
|
||||
const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate);
|
||||
|
||||
const subtitles: Subtitles[] = mediasSource.MediaStreams.filter(
|
||||
(s) => s.Type === 'Subtitle' && s.DeliveryUrl,
|
||||
).map((s, i) => ({
|
||||
src:
|
||||
this.getProxyUrl(userContext.sourceId) +
|
||||
`${s.DeliveryUrl}&reiverr_token=${userContext.token}`,
|
||||
lang: s.Language,
|
||||
kind: 'subtitles',
|
||||
label: s.DisplayTitle,
|
||||
}));
|
||||
|
||||
return {
|
||||
key: '0',
|
||||
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:
|
||||
config?.audioStreamIndex ??
|
||||
mediasSource?.DefaultAudioStreamIndex ??
|
||||
audioStreams[0].index,
|
||||
audioStreams,
|
||||
duration: mediasSource.RunTimeTicks
|
||||
? mediasSource.RunTimeTicks / 10_000_000
|
||||
: 0,
|
||||
progress: config?.progress ?? 0,
|
||||
qualities,
|
||||
qualityIndex: getClosestBitrate(qualities, bitrate).index,
|
||||
subtitles,
|
||||
src: playbackUri,
|
||||
// uri:
|
||||
// proxyUrl +
|
||||
// '/stream_new2/H4sIAAAAAAAAAw3OWXKDIAAA0Cvhggn9TBqSuJARBcU_CloiYp2Ojcvpm3eCB2EXASWjIAwRUkd4AF7XdYdQAY0kVPIjDTghrElZT0EJqGlv5I_64V5UOk58vOSO7F8bcjKYnvmusRg0zLe5Lv2YaWsSUpFMuTXOAAS5O66s_H5RBpbWrmftnV4JuIdZ8LNrf1laHs_FTqkMmro4z7CsSS7sRNpx2liFotJ5TPY45Q6tms3R45NSdYWGWZ6yvTm14.lXAV7r67IyOy85n5JHjQeFzV0z0guHo2YcrCzQQoEumgIZxrlQgQir2m4suLyPK22t6eX7nmG.Sn8SxRNdH7dBNKMxxGucvgyj8Lind4D.AeRg7d1BAQAA/master.m3u8' +
|
||||
// `?reiverr_token=${userContext.token}`,
|
||||
directPlay:
|
||||
!!mediasSource?.SupportsDirectPlay ||
|
||||
!!mediasSource?.SupportsDirectStream,
|
||||
};
|
||||
};
|
||||
|
||||
getEpisodeStream = async (
|
||||
tmdbId: string,
|
||||
metadata: EpisodeMetadata,
|
||||
key: string,
|
||||
userContext: UserContext,
|
||||
config?: PlaybackConfig,
|
||||
): Promise<Stream | undefined> => {
|
||||
const context = new PluginContext(userContext.settings, userContext.token);
|
||||
const items = await this.getLibraryItems(context);
|
||||
|
||||
const show = items.find(
|
||||
(item) => item.ProviderIds?.Tmdb === tmdbId,
|
||||
// && item.ParentIndexNumber === seasonNumber &&
|
||||
// item.IndexNumber === episodeNumber,
|
||||
);
|
||||
|
||||
const episode = items.find(
|
||||
(item) =>
|
||||
item.SeriesId === show?.Id &&
|
||||
item.IndexNumber === metadata.episode &&
|
||||
item.ParentIndexNumber === metadata.season,
|
||||
);
|
||||
|
||||
if (
|
||||
!episode ||
|
||||
!episode.MediaSources ||
|
||||
episode.MediaSources.length === 0
|
||||
) {
|
||||
throw SourceProviderError.StreamNotFound;
|
||||
}
|
||||
|
||||
/*
|
||||
await jellyfinApi.getPlaybackInfo(
|
||||
id,
|
||||
getDeviceProfile(),
|
||||
options.playbackPosition || item?.UserData?.PlaybackPositionTicks || 0,
|
||||
options.bitrate || getQualities(item?.Height || 1080)[0]?.maxBitrate,
|
||||
audioStreamIndex
|
||||
);
|
||||
*/
|
||||
|
||||
const startTimeTicks = episode.RunTimeTicks
|
||||
? Math.floor(episode.RunTimeTicks * (config?.progress ?? 0))
|
||||
: undefined;
|
||||
const maxStreamingBitrate = config?.bitrate || 0; //|| movie.MediaSources?.[0]?.Bitrate || 10000000
|
||||
|
||||
const playbackInfo = await context.api.items.getPostedPlaybackInfo(
|
||||
episode.Id,
|
||||
{
|
||||
DeviceProfile: config?.deviceProfile,
|
||||
},
|
||||
{
|
||||
userId: context.settings.userId,
|
||||
startTimeTicks: startTimeTicks || 0,
|
||||
...(maxStreamingBitrate ? { maxStreamingBitrate } : {}),
|
||||
autoOpenLiveStream: true,
|
||||
...(config?.audioStreamIndex
|
||||
? { audioStreamIndex: config?.audioStreamIndex }
|
||||
: {}),
|
||||
mediaSourceId: episode.Id,
|
||||
|
||||
// deviceId: JELLYFIN_DEVICE_ID,
|
||||
// mediaSourceId: movie.MediaSources[0].Id,
|
||||
// maxBitrate: 8000000,
|
||||
},
|
||||
);
|
||||
|
||||
const mediasSource = playbackInfo.data?.MediaSources?.[0];
|
||||
|
||||
const playbackUri =
|
||||
this.getProxyUrl(userContext.sourceId) +
|
||||
(mediasSource?.TranscodingUrl ||
|
||||
`/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${mediasSource?.ETag}`) +
|
||||
`&reiverr_token=${userContext.token}`;
|
||||
|
||||
const audioStreams: Stream['audioStreams'] =
|
||||
mediasSource?.MediaStreams.filter((s) => s.Type === 'Audio').map((s) => ({
|
||||
bitrate: s.BitRate,
|
||||
label: s.Language,
|
||||
codec: s.Codec,
|
||||
index: s.Index,
|
||||
})) ?? [];
|
||||
|
||||
const qualities: Stream['qualities'] = [
|
||||
...bitrateQualities,
|
||||
{
|
||||
bitrate: mediasSource.Bitrate,
|
||||
label: 'Original',
|
||||
codec: undefined,
|
||||
original: true,
|
||||
},
|
||||
].map((q, i) => ({
|
||||
...q,
|
||||
index: i,
|
||||
}));
|
||||
|
||||
const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate);
|
||||
|
||||
const subtitles: Subtitles[] = mediasSource.MediaStreams.filter(
|
||||
(s) => s.Type === 'Subtitle' && s.DeliveryUrl,
|
||||
).map((s, i) => ({
|
||||
src:
|
||||
this.getProxyUrl(userContext.sourceId) +
|
||||
`${s.DeliveryUrl}&reiverr_token=${userContext.token}`,
|
||||
lang: s.Language,
|
||||
kind: 'subtitles',
|
||||
label: s.DisplayTitle,
|
||||
}));
|
||||
|
||||
return {
|
||||
key: '0',
|
||||
title: episode.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:
|
||||
config?.audioStreamIndex ??
|
||||
mediasSource?.DefaultAudioStreamIndex ??
|
||||
audioStreams[0].index,
|
||||
audioStreams,
|
||||
duration: mediasSource.RunTimeTicks
|
||||
? mediasSource.RunTimeTicks / 10_000_000
|
||||
: 0,
|
||||
progress: config?.progress ?? 0,
|
||||
qualities,
|
||||
qualityIndex: getClosestBitrate(qualities, bitrate).index,
|
||||
subtitles,
|
||||
src: playbackUri,
|
||||
// uri:
|
||||
// proxyUrl +
|
||||
// '/stream_new2/H4sIAAAAAAAAAw3OWXKDIAAA0Cvhggn9TBqSuJARBcU_CloiYp2Ojcvpm3eCB2EXASWjIAwRUkd4AF7XdYdQAY0kVPIjDTghrElZT0EJqGlv5I_64V5UOk58vOSO7F8bcjKYnvmusRg0zLe5Lv2YaWsSUpFMuTXOAAS5O66s_H5RBpbWrmftnV4JuIdZ8LNrf1laHs_FTqkMmro4z7CsSS7sRNpx2liFotJ5TPY45Q6tms3R45NSdYWGWZ6yvTm14.lXAV7r67IyOy85n5JHjQeFzV0z0guHo2YcrCzQQoEumgIZxrlQgQir2m4suLyPK22t6eX7nmG.Sn8SxRNdH7dBNKMxxGucvgyj8Lind4D.AeRg7d1BAQAA/master.m3u8' +
|
||||
// `?reiverr_token=${userContext.token}`,
|
||||
directPlay:
|
||||
!!mediasSource?.SupportsDirectPlay ||
|
||||
!!mediasSource?.SupportsDirectStream,
|
||||
};
|
||||
};
|
||||
|
||||
proxyHandler = async (
|
||||
req: any,
|
||||
res: any,
|
||||
options: { context: UserContext; uri: string; targetUrl?: string },
|
||||
): Promise<any> => {
|
||||
const { context, uri, targetUrl } = options;
|
||||
const settings = context.settings as JellyfinSettings;
|
||||
|
||||
const url = settings.baseUrl + uri;
|
||||
|
||||
const headers = {};
|
||||
for (const key in req.headers) {
|
||||
if (key === 'host') continue;
|
||||
headers[key] = req.headers[key];
|
||||
}
|
||||
|
||||
const proxyRes = await fetch(url, {
|
||||
method: req.method || 'GET',
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`,
|
||||
},
|
||||
}).catch((e) => {
|
||||
console.error('error fetching proxy response', e);
|
||||
res.status(500).send('Error fetching proxy response');
|
||||
});
|
||||
|
||||
if (!proxyRes) return;
|
||||
|
||||
proxyRes.headers.forEach((value, name) => {
|
||||
res.setHeader(name, value);
|
||||
});
|
||||
|
||||
res.status(proxyRes.status);
|
||||
Readable.from(proxyRes.body).pipe(res);
|
||||
};
|
||||
}
|
||||
24948
backend/packages/jellyfin.plugin/src/jellyfin.openapi.ts
Normal file
24948
backend/packages/jellyfin.plugin/src/jellyfin.openapi.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
backend/packages/jellyfin.plugin/src/plugin-context.ts
Normal file
36
backend/packages/jellyfin.plugin/src/plugin-context.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
SourceProviderSettings,
|
||||
UserContext,
|
||||
} from '@aleksilassila/reiverr-plugin';
|
||||
import { Api as JellyfinApi } from './jellyfin.openapi';
|
||||
import { JELLYFIN_DEVICE_ID } from './utils';
|
||||
|
||||
export interface JellyfinSettings extends SourceProviderSettings {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface JellyfinUserContext extends UserContext {
|
||||
settings: JellyfinSettings;
|
||||
}
|
||||
|
||||
export class PluginContext {
|
||||
api: JellyfinApi<unknown>;
|
||||
settings: JellyfinSettings;
|
||||
token: string;
|
||||
|
||||
constructor(settings: SourceProviderSettings, token = '') {
|
||||
this.token = token;
|
||||
this.settings = settings as JellyfinSettings;
|
||||
this.api = new JellyfinApi({
|
||||
baseURL: settings.baseUrl,
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`,
|
||||
},
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
88
backend/packages/jellyfin.plugin/src/settings.ts
Normal file
88
backend/packages/jellyfin.plugin/src/settings.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
SettingsManager,
|
||||
SourceProviderSettingsTemplate,
|
||||
ValidationResponse,
|
||||
} from '@aleksilassila/reiverr-plugin';
|
||||
import { PluginContext } from './plugin-context';
|
||||
|
||||
export class JellyfinSettingsManager extends SettingsManager {
|
||||
validateSettings = async (
|
||||
settings: Record<string, any>,
|
||||
): Promise<ValidationResponse> => {
|
||||
let isValid = true;
|
||||
const errors = {
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
userId: '',
|
||||
};
|
||||
const replace: Record<string, any> = {};
|
||||
|
||||
if (!settings.baseUrl) {
|
||||
isValid = false;
|
||||
errors.baseUrl = 'Base URL is required';
|
||||
}
|
||||
|
||||
if (!settings.apiKey) {
|
||||
isValid = false;
|
||||
errors.apiKey = 'API Key is required';
|
||||
}
|
||||
|
||||
if (!settings.userId) {
|
||||
isValid = false;
|
||||
errors.userId = 'User ID is required';
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
const context = new PluginContext(settings as any);
|
||||
let [user, err] = await context.api.users
|
||||
.getUserById(settings.userId)
|
||||
.then((res) => [res.data, undefined])
|
||||
.catch((err) => [undefined, err.message]);
|
||||
|
||||
if (!user && err) {
|
||||
[user, err] = await context.api.users
|
||||
.getUsers()
|
||||
.then((res) => res.data?.find((u) => u.Name === settings.userId))
|
||||
.then((user) => {
|
||||
if (!user || !user.Id) {
|
||||
return [undefined, 'User not found'];
|
||||
}
|
||||
|
||||
replace.userId = user.Id;
|
||||
|
||||
return [user, undefined];
|
||||
})
|
||||
.catch((err) => [undefined, err.message]);
|
||||
}
|
||||
|
||||
if (!user && err) {
|
||||
isValid = false;
|
||||
errors.userId = `Could not get user: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid,
|
||||
errors,
|
||||
replace,
|
||||
};
|
||||
};
|
||||
|
||||
getSettingsTemplate: () => SourceProviderSettingsTemplate = () => ({
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
label: 'Base URL',
|
||||
placeholder: 'http://localhost:8096',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'password',
|
||||
label: 'API Key',
|
||||
placeholder: '',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
label: 'Username or User ID',
|
||||
placeholder: 'username or user id',
|
||||
},
|
||||
});
|
||||
}
|
||||
93
backend/packages/jellyfin.plugin/src/utils.ts
Normal file
93
backend/packages/jellyfin.plugin/src/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
|
||||
|
||||
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'
|
||||
}`;
|
||||
}
|
||||
Reference in New Issue
Block a user