build: make file downloads persistent in docker, refactor built-in plugin workspaces

This commit is contained in:
Aleksi Lassila
2025-02-12 00:37:41 +02:00
parent b46a65a93c
commit 9d496ad014
30 changed files with 56 additions and 3193 deletions

View 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',
});

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

File diff suppressed because it is too large Load Diff

View 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,
},
});
}
}

View 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',
},
});
}

View 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'
}`;
}