mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 16:55:13 +02:00
feat: overhaul dockerfile
This commit is contained in:
91
torrent-stream.plugin/src/index.ts
Normal file
91
torrent-stream.plugin/src/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
CatalogueProvider,
|
||||
MediaSourceProvider,
|
||||
ReiverrPlugin,
|
||||
SourceProviderSettings,
|
||||
SourceProviderSettingsTemplate,
|
||||
UserContext,
|
||||
ValidationResponse,
|
||||
} from '@aleksilassila/reiverr-shared';
|
||||
import { testConnection } from './lib/jackett.api';
|
||||
import { TorrentMediaSourceProvider } from './media-source-provider';
|
||||
|
||||
class TorrentCatalogueProvider extends CatalogueProvider {}
|
||||
|
||||
class TorrentPlugin extends ReiverrPlugin {
|
||||
name: string = 'torrent';
|
||||
|
||||
getCatalogueProvider: (options: {
|
||||
userId: string;
|
||||
sourceId: string;
|
||||
settings: SourceProviderSettings;
|
||||
}) => CatalogueProvider = (options) => new TorrentCatalogueProvider(options);
|
||||
|
||||
getMediaSourceProvider: (
|
||||
options: {
|
||||
userId: string;
|
||||
sourceId: string;
|
||||
settings: SourceProviderSettings;
|
||||
} & { token: string },
|
||||
) => MediaSourceProvider = (options) =>
|
||||
new TorrentMediaSourceProvider(options);
|
||||
|
||||
// getMediaSourceProvider: (userContext: UserContext) => MediaSourceProvider = (
|
||||
// context,
|
||||
// ) => new TorrentMediaSourceProvider(context);
|
||||
|
||||
getSettingsTemplate: () => SourceProviderSettingsTemplate = () => ({
|
||||
baseUrl: {
|
||||
type: 'string',
|
||||
label: 'Jackett URL',
|
||||
placeholder:
|
||||
'http://127.0.0.1:9117/api/v2.0/indexers/indexer/results/torznab/',
|
||||
required: true,
|
||||
},
|
||||
apiKey: {
|
||||
type: 'password',
|
||||
label: 'Jackett API Key',
|
||||
placeholder: '',
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
validateSettings: (options: {
|
||||
settings: Record<string, any>;
|
||||
}) => Promise<ValidationResponse> = async ({ settings }) => {
|
||||
const { baseUrl, apiKey } = settings;
|
||||
let isValid = true;
|
||||
const errors = {
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
};
|
||||
|
||||
if (!baseUrl) {
|
||||
isValid = false;
|
||||
errors.baseUrl = 'Base URL is required';
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
isValid = false;
|
||||
errors.apiKey = 'API Key is required';
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
await testConnection({ baseUrl, apiKey })
|
||||
.then((err) => {
|
||||
if (err) {
|
||||
isValid = false;
|
||||
errors.apiKey = err;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
isValid = false;
|
||||
errors.baseUrl = e.message ?? 'Invalid URL';
|
||||
});
|
||||
}
|
||||
|
||||
return { isValid, errors, settings };
|
||||
};
|
||||
}
|
||||
|
||||
export default new TorrentPlugin();
|
||||
229
torrent-stream.plugin/src/lib/jackett.api.ts
Normal file
229
torrent-stream.plugin/src/lib/jackett.api.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { StreamCandidate } from '@aleksilassila/reiverr-shared';
|
||||
import { TorrentSettings } from '../types';
|
||||
import { formatSize, formatBitrate, EPISODE_SEPARATOR } from '../utils';
|
||||
|
||||
export type JackettItem = {
|
||||
title: string;
|
||||
guid: string;
|
||||
jackettindexer: string;
|
||||
type: string;
|
||||
comments: string;
|
||||
pubDate: string;
|
||||
size: number;
|
||||
files: number;
|
||||
description: string;
|
||||
link: string;
|
||||
category: string[];
|
||||
enclosure: {
|
||||
url: string;
|
||||
length: number;
|
||||
type: string;
|
||||
};
|
||||
'torznab:attr': {
|
||||
'@_name': string;
|
||||
'@_value': string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type JackettResponse = {
|
||||
items: Promise<JackettItem[]>;
|
||||
seasonPacks: Promise<JackettItem[]>;
|
||||
get: (infohash: string) => Promise<JackettItem | undefined>;
|
||||
};
|
||||
|
||||
const jackettXmlParser = new XMLParser({ ignoreAttributes: false });
|
||||
|
||||
function getTorrentWithInfohash(
|
||||
items: JackettItem[],
|
||||
infohash: string,
|
||||
): JackettItem | undefined {
|
||||
return items.find((i) => getTorrentAttribute(i, 'infohash') === infohash);
|
||||
}
|
||||
|
||||
export const getTorrentAttribute = (
|
||||
item: JackettItem,
|
||||
attribute: string,
|
||||
): string => {
|
||||
return (
|
||||
item['torznab:attr']?.find((i) => i['@_name'] === attribute)?.['@_value'] ??
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
export const getMovieTorrents = (
|
||||
settings: TorrentSettings,
|
||||
title: string,
|
||||
year: number,
|
||||
): JackettResponse => {
|
||||
const torrents = axios
|
||||
.get(`/api`, {
|
||||
baseURL: settings.baseUrl,
|
||||
params: {
|
||||
apikey: settings.apiKey,
|
||||
t: 'movie',
|
||||
q: `{${title} ${year}}`,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
(res) =>
|
||||
jackettXmlParser.parse(res.data)?.rss?.channel?.item as JackettItem[],
|
||||
);
|
||||
|
||||
return {
|
||||
items: torrents,
|
||||
seasonPacks: Promise.resolve([]),
|
||||
get: (infohash) =>
|
||||
torrents.then((torrents) => getTorrentWithInfohash(torrents, infohash)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getEpisodeTorrents = (
|
||||
settings: TorrentSettings,
|
||||
series: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
): JackettResponse => {
|
||||
const nameEscaped = series.replace("'", '');
|
||||
const torrents = axios
|
||||
.get(`/api`, {
|
||||
baseURL: settings.baseUrl,
|
||||
params: {
|
||||
apikey: settings.apiKey,
|
||||
t: 'tvsearch',
|
||||
q: `${nameEscaped} S${season.toString().padStart(2, '0')}E${episode
|
||||
.toString()
|
||||
.padStart(2, '0')}`,
|
||||
// q: `${series}`, // `${series} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`
|
||||
// season: season,
|
||||
// episode: episode,
|
||||
},
|
||||
})
|
||||
.then((res) => jackettXmlParser.parse(res.data)?.rss?.channel?.item ?? [])
|
||||
.then((items) => (Array.isArray(items) ? items : [items]));
|
||||
|
||||
const seasonPacks = axios
|
||||
.get(`/api`, {
|
||||
baseURL: settings.baseUrl,
|
||||
params: {
|
||||
apikey: settings.apiKey,
|
||||
t: 'tvsearch',
|
||||
q: `${nameEscaped}`,
|
||||
// q: `${series}`, // `${series} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`
|
||||
season: season,
|
||||
// episode: episode,
|
||||
},
|
||||
})
|
||||
.then((res) => jackettXmlParser.parse(res.data)?.rss?.channel?.item ?? [])
|
||||
.then((items) => (Array.isArray(items) ? items : [items]));
|
||||
|
||||
const combined = Promise.all([torrents, seasonPacks]).then(
|
||||
([torrents, seasonPacks]) => [...torrents, ...seasonPacks],
|
||||
);
|
||||
|
||||
return {
|
||||
items: torrents,
|
||||
seasonPacks: seasonPacks,
|
||||
get: (infohash) =>
|
||||
combined.then((torrents) => getTorrentWithInfohash(torrents, infohash)),
|
||||
};
|
||||
};
|
||||
|
||||
export function getStreamCandidates(
|
||||
torrents: JackettItem[],
|
||||
options: {
|
||||
runtime?: number;
|
||||
files?: number;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
} = {},
|
||||
): StreamCandidate[] {
|
||||
return torrents.map((torrent) => {
|
||||
const { runtime = 0, files = 1, season, episode } = options;
|
||||
|
||||
const seeders = Number(getTorrentAttribute(torrent, 'seeders')) || 0;
|
||||
const peers = Number(getTorrentAttribute(torrent, 'peers')) || 0;
|
||||
const sizePerFile = (Number(torrent.size) || 0) / files;
|
||||
|
||||
const swarmSize = seeders + peers;
|
||||
|
||||
const sizePerPeer =
|
||||
swarmSize > 0 && files > 0 ? sizePerFile / swarmSize : 0;
|
||||
const bitrate = runtime > 0 && files > 0 ? sizePerFile / runtime : 0;
|
||||
|
||||
return {
|
||||
streamId:
|
||||
getTorrentAttribute(torrent, 'infohash') +
|
||||
(season !== undefined && episode !== undefined
|
||||
? `${EPISODE_SEPARATOR}${season}${EPISODE_SEPARATOR}${episode}`
|
||||
: ''),
|
||||
title: torrent.title || torrent.description,
|
||||
actions: [
|
||||
{
|
||||
label: 'Stream',
|
||||
type: 'stream',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
label: 'Seeders',
|
||||
value: seeders,
|
||||
formatted: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Peers',
|
||||
value: peers,
|
||||
formatted: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Size' + (files > 1 ? '/file' : ''),
|
||||
value: sizePerFile,
|
||||
formatted: formatSize(sizePerFile),
|
||||
},
|
||||
...(files > 1
|
||||
? [{ label: 'Files', value: files, formatted: undefined }]
|
||||
: []),
|
||||
...(bitrate > 0
|
||||
? [
|
||||
{
|
||||
label: 'Bitrate',
|
||||
value: bitrate,
|
||||
formatted: formatBitrate(bitrate),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// ...(bitrate === 0 && sizePerPeer > 0
|
||||
// ? [
|
||||
// {
|
||||
// label: 'Size/peer',
|
||||
// value: sizePerPeer,
|
||||
// formatted: formatSize(sizePerPeer),
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function testConnection(settings: TorrentSettings) {
|
||||
return axios
|
||||
.get(`/api`, {
|
||||
baseURL: settings.baseUrl,
|
||||
params: {
|
||||
apikey: settings.apiKey,
|
||||
},
|
||||
timeout: 5000,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status >= 400) {
|
||||
return 'Could not connect to Jackett: ' + res.statusText;
|
||||
}
|
||||
|
||||
const data = jackettXmlParser.parse(res.data);
|
||||
if (data.error) {
|
||||
return data.error['@_description'] || 'Unknown error';
|
||||
}
|
||||
});
|
||||
}
|
||||
177
torrent-stream.plugin/src/lib/torrent-manager.ts
Normal file
177
torrent-stream.plugin/src/lib/torrent-manager.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as torrentStream from 'torrent-stream';
|
||||
|
||||
class FileCache<T> {
|
||||
private cache: T;
|
||||
|
||||
constructor(private cacheFile: string, private defaultValue: T) {
|
||||
this.cache = this.readStreamCache();
|
||||
}
|
||||
|
||||
private writeStreamCache(cache: T) {
|
||||
this.cache = cache;
|
||||
fs.writeFileSync(this.cacheFile, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
private readStreamCache(): T {
|
||||
if (fs.existsSync(this.cacheFile)) {
|
||||
const data = fs.readFileSync(this.cacheFile, 'utf8');
|
||||
if (!data) return this.defaultValue;
|
||||
return JSON.parse(data) ?? this.defaultValue;
|
||||
}
|
||||
|
||||
return this.defaultValue;
|
||||
}
|
||||
|
||||
update(fn: (value: T) => T): T {
|
||||
const n = fn(this.get());
|
||||
this.cache = n;
|
||||
this.writeStreamCache(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
get(): T {
|
||||
return this.cache ?? this.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
type TorrentMetadata = {
|
||||
userId: string;
|
||||
infoHash: string;
|
||||
lastAccessed: number;
|
||||
};
|
||||
|
||||
type TorrentEntry = {
|
||||
engine: TorrentStream.TorrentEngine;
|
||||
metadata: TorrentMetadata;
|
||||
};
|
||||
|
||||
class EngineCache {
|
||||
// maxFileCacheSize = 5 * 1024 * 1024 * 1024;
|
||||
maxActiveTorrentsPerUser = 1;
|
||||
maxTorrentKeepAlive = 1000 * 60 * 60 * 24 * 3;
|
||||
|
||||
private userTorrentMetadata = new FileCache<{
|
||||
[userId: string]: TorrentMetadata;
|
||||
}>(path.join(__dirname, '..', '..', 'stream-cache.json'), {});
|
||||
private engineCache: {
|
||||
[infoHash: string]: Promise<TorrentStream.TorrentEngine>;
|
||||
} = {};
|
||||
|
||||
constructor() {
|
||||
for (const metadata of Object.values(this.userTorrentMetadata.get())) {
|
||||
if (metadata.lastAccessed > Date.now() - this.maxTorrentKeepAlive) {
|
||||
console.log('initializing old torrent', metadata);
|
||||
this.getTorrent(metadata.infoHash);
|
||||
} else {
|
||||
console.log('discarding old torrent', metadata.infoHash);
|
||||
this.userTorrentMetadata.update((m) => {
|
||||
delete m[metadata.userId];
|
||||
return m;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyEngine(infoHash: string) {
|
||||
const engine = await this.engineCache[infoHash];
|
||||
|
||||
await new Promise((res) => {
|
||||
engine?.remove
|
||||
? engine?.remove(false, () => {
|
||||
res(undefined);
|
||||
})
|
||||
: res(undefined);
|
||||
});
|
||||
|
||||
await new Promise((res) => {
|
||||
engine?.destroy ? engine?.destroy(() => res(undefined)) : res(undefined);
|
||||
});
|
||||
|
||||
this.userTorrentMetadata.update((metadata) => {
|
||||
const toDelete = Object.values(metadata).find(
|
||||
(m) => (m.infoHash = infoHash),
|
||||
);
|
||||
if (toDelete) delete metadata[toDelete.userId];
|
||||
return metadata;
|
||||
});
|
||||
delete this.engineCache[infoHash];
|
||||
console.log('destroyed', infoHash);
|
||||
}
|
||||
|
||||
private async getTorrent(
|
||||
magnetLink: string,
|
||||
): Promise<TorrentStream.TorrentEngine> {
|
||||
if (magnetLink in this.engineCache) {
|
||||
return this.engineCache[magnetLink];
|
||||
}
|
||||
|
||||
const promise = new Promise<TorrentStream.TorrentEngine>((res, rej) => {
|
||||
const engine = torrentStream(magnetLink, {
|
||||
tmp: process.env.TORRENT_STREAM_DOWNLOADS?.startsWith('/')
|
||||
? process.env.TORRENT_STREAM_DOWNLOADS
|
||||
: path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
process.env.TORRENT_STREAM_DOWNLOADS ??
|
||||
'torrent-stream-downloads',
|
||||
),
|
||||
});
|
||||
engine.on('ready', () => {
|
||||
res(engine);
|
||||
});
|
||||
engine.on('download', (e) => console.info('onDownload', magnetLink, e));
|
||||
engine.on('upload', (e) => console.info('onUpload', magnetLink, e));
|
||||
});
|
||||
|
||||
this.engineCache[magnetLink] = promise;
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
private getUserActiveTorrent(userId: string): TorrentMetadata | undefined {
|
||||
return this.userTorrentMetadata.get()[userId];
|
||||
}
|
||||
|
||||
private async setUserActiveTorrent(userId: string, magnetLink: string) {
|
||||
const activeTorrent = this.getUserActiveTorrent(userId);
|
||||
|
||||
if (activeTorrent && activeTorrent.infoHash !== magnetLink) {
|
||||
console.log(
|
||||
'destroying old torrent',
|
||||
activeTorrent.infoHash,
|
||||
magnetLink,
|
||||
activeTorrent.infoHash === magnetLink,
|
||||
);
|
||||
await this.destroyEngine(activeTorrent.infoHash).finally(() => {
|
||||
console.log('destroyed');
|
||||
});
|
||||
}
|
||||
|
||||
this.userTorrentMetadata.update((metadata) => {
|
||||
metadata[userId] = {
|
||||
userId,
|
||||
infoHash: magnetLink,
|
||||
lastAccessed: Date.now(),
|
||||
};
|
||||
return metadata;
|
||||
});
|
||||
}
|
||||
|
||||
async getFiles(userId: string, magnetLink: string) {
|
||||
await this.setUserActiveTorrent(userId, magnetLink);
|
||||
const engine = await this.getTorrent(magnetLink);
|
||||
return engine.files;
|
||||
}
|
||||
}
|
||||
|
||||
const engineCache = new EngineCache();
|
||||
|
||||
export function getFiles(
|
||||
userId: string,
|
||||
magnetLink: string,
|
||||
): Promise<TorrentStream.TorrentFile[]> {
|
||||
return engineCache.getFiles(userId, magnetLink);
|
||||
}
|
||||
430
torrent-stream.plugin/src/media-source-provider.ts
Normal file
430
torrent-stream.plugin/src/media-source-provider.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import {
|
||||
ActionResponse,
|
||||
ListWithDetailsView,
|
||||
MediaSourceProvider,
|
||||
MediaSourceView,
|
||||
MediaSourceViews,
|
||||
PlaybackConfig,
|
||||
StreamAction,
|
||||
StreamActionElement,
|
||||
StreamBase,
|
||||
StreamCandidate,
|
||||
StreamResponse,
|
||||
Subtitles,
|
||||
UserContext,
|
||||
ViewBase,
|
||||
} from '@aleksilassila/reiverr-shared';
|
||||
import {
|
||||
getEpisodeTorrents,
|
||||
getMovieTorrents,
|
||||
getStreamCandidates,
|
||||
} from './lib/jackett.api';
|
||||
import { getFiles } from './lib/torrent-manager';
|
||||
import type { TorrentSettings } from './types';
|
||||
import {
|
||||
EPISODE_SEPARATOR,
|
||||
getContentType,
|
||||
srt2webvtt,
|
||||
subtitleExtensions,
|
||||
videoExtensions,
|
||||
} from './utils';
|
||||
|
||||
export const movieStreamView = {
|
||||
id: 'stream-movie',
|
||||
type: 'list-with-details',
|
||||
label: 'Stream',
|
||||
priority: 0,
|
||||
} satisfies ViewBase;
|
||||
|
||||
export const episodeStreamView = {
|
||||
id: 'stream-episode',
|
||||
type: 'list-with-details',
|
||||
label: 'Stream',
|
||||
priority: 0,
|
||||
} satisfies ViewBase;
|
||||
|
||||
export const streamAction = {
|
||||
action: 'stream',
|
||||
label: 'Stream',
|
||||
type: 'action',
|
||||
icon: {
|
||||
type: 'play',
|
||||
},
|
||||
} satisfies StreamActionElement;
|
||||
|
||||
export class TorrentMediaSourceProvider extends MediaSourceProvider {
|
||||
private proxyUrl: string;
|
||||
|
||||
constructor(context: UserContext) {
|
||||
super(context);
|
||||
this.proxyUrl = `/api/sources/${this.sourceId}/proxy`;
|
||||
}
|
||||
|
||||
getMeidaSourceViews: (options: {
|
||||
tmdbMovie?: any;
|
||||
tmdbSeries?: any;
|
||||
tmdbEpisode?: any;
|
||||
}) => Promise<{ views: MediaSourceViews }> = async ({
|
||||
tmdbMovie,
|
||||
tmdbSeries,
|
||||
tmdbEpisode,
|
||||
}) => {
|
||||
const views: MediaSourceViews = [];
|
||||
|
||||
if (tmdbMovie) {
|
||||
views.push(movieStreamView);
|
||||
} else if (tmdbSeries && tmdbEpisode) {
|
||||
views.push(episodeStreamView);
|
||||
}
|
||||
|
||||
return {
|
||||
views,
|
||||
};
|
||||
};
|
||||
|
||||
getMediaSourceView: (
|
||||
options: { tmdbMovie?: any; tmdbSeries?: any; tmdbEpisode?: any } & {
|
||||
id: string;
|
||||
},
|
||||
) => Promise<{ view?: MediaSourceView }> = async ({
|
||||
id,
|
||||
tmdbMovie,
|
||||
tmdbSeries,
|
||||
tmdbEpisode,
|
||||
}) => {
|
||||
let view: MediaSourceView | undefined;
|
||||
|
||||
if (id === movieStreamView.id && tmdbMovie) {
|
||||
const { candidates } = await this.getTmdbMovieCandidates({
|
||||
tmdbMovie,
|
||||
});
|
||||
|
||||
const view: ListWithDetailsView = {
|
||||
...movieStreamView,
|
||||
items: candidates.map((c) => ({
|
||||
id: c.streamId,
|
||||
label: c.title,
|
||||
properties: c.properties,
|
||||
actions: [streamAction],
|
||||
})),
|
||||
orderOptions: [],
|
||||
order: undefined,
|
||||
};
|
||||
|
||||
return { view };
|
||||
} else if (id === episodeStreamView.id && tmdbSeries && tmdbEpisode) {
|
||||
const { candidates } = await this.getTmdbEpisodeCandidates({
|
||||
tmdbSeries,
|
||||
tmdbEpisode,
|
||||
});
|
||||
|
||||
const view: ListWithDetailsView = {
|
||||
...episodeStreamView,
|
||||
items: candidates.map((c) => ({
|
||||
id: c.streamId,
|
||||
label: c.title,
|
||||
properties: c.properties,
|
||||
actions: [streamAction],
|
||||
})),
|
||||
orderOptions: [],
|
||||
order: undefined,
|
||||
};
|
||||
|
||||
return { view };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
getAutoplayStream: (options: {
|
||||
tmdbMovie?: any;
|
||||
tmdbSeries?: any;
|
||||
tmdbEpisode?: any;
|
||||
}) => Promise<{ candidate?: StreamBase }> = async ({
|
||||
tmdbMovie,
|
||||
tmdbSeries,
|
||||
tmdbEpisode,
|
||||
}) => {
|
||||
if (tmdbMovie) {
|
||||
const { candidates } = await this.getTmdbMovieCandidates({
|
||||
tmdbMovie,
|
||||
});
|
||||
|
||||
return { candidate: candidates[0] };
|
||||
} else if (tmdbSeries && tmdbEpisode) {
|
||||
const { candidates } = await this.getTmdbEpisodeCandidates({
|
||||
tmdbSeries,
|
||||
tmdbEpisode,
|
||||
});
|
||||
|
||||
return { candidate: candidates[0] };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
getTmdbMovieCandidates: (options: {
|
||||
tmdbMovie: any;
|
||||
}) => Promise<{ candidates: StreamCandidate[] }> = async ({ tmdbMovie }) => {
|
||||
const settings = this.settings as TorrentSettings;
|
||||
|
||||
const year = tmdbMovie.release_date
|
||||
? new Date(tmdbMovie.release_date).getFullYear()
|
||||
: undefined;
|
||||
|
||||
if (!tmdbMovie.title || !year) return { candidates: [] };
|
||||
const torrents = await getMovieTorrents(settings, tmdbMovie.title, year)
|
||||
.items;
|
||||
|
||||
const candidates = getStreamCandidates(torrents, {
|
||||
runtime: tmdbMovie.runtime,
|
||||
});
|
||||
|
||||
return { candidates };
|
||||
};
|
||||
|
||||
getTmdbEpisodeCandidates: (options: {
|
||||
tmdbSeries: any;
|
||||
tmdbEpisode: any;
|
||||
}) => Promise<{ candidates: StreamCandidate[] }> = async ({
|
||||
tmdbSeries,
|
||||
tmdbEpisode,
|
||||
}) => {
|
||||
const settings = this.settings as TorrentSettings;
|
||||
|
||||
const torrents = getEpisodeTorrents(
|
||||
settings,
|
||||
tmdbSeries.name,
|
||||
tmdbEpisode.season_number,
|
||||
tmdbEpisode.episode_number,
|
||||
);
|
||||
const items = await torrents.items;
|
||||
const seasonPacks = await torrents.seasonPacks;
|
||||
|
||||
const seasonEpisodes =
|
||||
tmdbSeries?.seasons?.find(
|
||||
(s: any) => s.season_number === tmdbEpisode.season_number,
|
||||
)?.episode_count ?? 1;
|
||||
const candidates = [
|
||||
...getStreamCandidates(items, {
|
||||
runtime: tmdbEpisode.runtime,
|
||||
season: tmdbEpisode.season_number,
|
||||
episode: tmdbEpisode.episode_number,
|
||||
}),
|
||||
...getStreamCandidates(seasonPacks, {
|
||||
runtime: tmdbEpisode.runtime,
|
||||
files: seasonEpisodes,
|
||||
season: tmdbEpisode.season_number,
|
||||
episode: tmdbEpisode.episode_number,
|
||||
}),
|
||||
];
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const aSeeders =
|
||||
Number(a.properties.find((p) => p.label === 'Seeders')?.value) || 0;
|
||||
const bSeeders =
|
||||
Number(b.properties.find((p) => p.label === 'Seeders')?.value) || 0;
|
||||
const aPeers =
|
||||
Number(a.properties.find((p) => p.label === 'Peers')?.value) || 0;
|
||||
const bPeers =
|
||||
Number(b.properties.find((p) => p.label === 'Peers')?.value) || 0;
|
||||
|
||||
if (aSeeders + aPeers > bSeeders + bPeers) return -1;
|
||||
if (aSeeders + aPeers < bSeeders + bPeers) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { candidates };
|
||||
};
|
||||
|
||||
handleAction: (options: {
|
||||
targetId: string;
|
||||
action: string;
|
||||
}) => Promise<ActionResponse> = async (options) => {
|
||||
// if (options.action === 'stream') {
|
||||
// return this.getStream({
|
||||
// streamId: options.targetId,
|
||||
// config: options.config,
|
||||
// }).then((stream) => ({ stream }));
|
||||
// }
|
||||
|
||||
return {
|
||||
error: {
|
||||
message: 'Action not supported',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getStream: (options: {
|
||||
streamId: string;
|
||||
config?: PlaybackConfig;
|
||||
}) => Promise<StreamResponse> = async ({ streamId, config }) => {
|
||||
const settings = this.settings as TorrentSettings;
|
||||
const [link, season, episode] = streamId.split(EPISODE_SEPARATOR);
|
||||
|
||||
// const torrent = await getEpisodeTorrents(
|
||||
// settings,
|
||||
// metadata.series,
|
||||
// metadata.season,
|
||||
// metadata.episode,
|
||||
// ).get(key);
|
||||
|
||||
if (!link) {
|
||||
throw new Error('Torrent not found');
|
||||
}
|
||||
|
||||
let src = `${this.proxyUrl}/magnet?link=${encodeURIComponent(link)}&reiverr_token=${
|
||||
this.token
|
||||
}`;
|
||||
|
||||
if (season && episode) {
|
||||
src += `&season=${season}&episode=${episode}`;
|
||||
}
|
||||
|
||||
const files = await getFiles(this.userId, link);
|
||||
|
||||
const subtitles: Subtitles[] = files
|
||||
.filter((f) => subtitleExtensions.some((ext) => f.name.endsWith(ext)))
|
||||
.map((f) => ({
|
||||
kind: 'subtitles',
|
||||
src: `${this.proxyUrl}/magnet?link=${encodeURIComponent(link)}&reiverr_token=${
|
||||
this.token
|
||||
}&file=${f.name}`,
|
||||
label: f.name,
|
||||
lang: 'unknown',
|
||||
}));
|
||||
|
||||
const stream = {
|
||||
streamId,
|
||||
src,
|
||||
audioStreamIndex: 0,
|
||||
audioStreams: [],
|
||||
duration: 0,
|
||||
properties: [],
|
||||
progress: config?.progress || 0,
|
||||
qualities: [],
|
||||
qualityIndex: 0,
|
||||
subtitles,
|
||||
title: 'Unknown',
|
||||
directPlay: true,
|
||||
};
|
||||
|
||||
return {
|
||||
stream,
|
||||
};
|
||||
};
|
||||
|
||||
proxyHandler: (options: {
|
||||
req: any;
|
||||
res: any;
|
||||
uri: string;
|
||||
targetUrl?: string;
|
||||
}) => Promise<any> = async ({ req, res, uri, targetUrl }) => {
|
||||
const settings = this.settings as TorrentSettings;
|
||||
|
||||
const params = new URLSearchParams(uri.split('?').slice(1).join('?'));
|
||||
const magnetLink = params.get('link');
|
||||
const fileName = params.get('file');
|
||||
const season = params.get('season');
|
||||
const episode = params.get('episode');
|
||||
|
||||
console.log('magnetLink', magnetLink);
|
||||
|
||||
if (!magnetLink) {
|
||||
res.status(400).send('No magnet link provided');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await getFiles(this.userId, magnetLink);
|
||||
|
||||
let file: TorrentStream.TorrentFile | undefined;
|
||||
|
||||
if (fileName) {
|
||||
file = files.find((f) => f.name === fileName);
|
||||
} else {
|
||||
const videoFiles = files.filter((f) =>
|
||||
videoExtensions.some((ext) => f.name.endsWith(ext)),
|
||||
);
|
||||
file =
|
||||
videoFiles.length > 1 && season && episode
|
||||
? videoFiles.find((f) => {
|
||||
const name = f.name.toUpperCase();
|
||||
return (
|
||||
name.includes(
|
||||
`S${season.toString().padStart(2, '0')}E${episode
|
||||
.toString()
|
||||
.padStart(2, '0')}`,
|
||||
) ||
|
||||
name.includes(`S${season.toString()}E${episode.toString()}`) ||
|
||||
name.includes(
|
||||
`${season.toString().padStart(2, '0')}X${episode
|
||||
.toString()
|
||||
.padStart(2, '0')}`,
|
||||
) ||
|
||||
name.includes(`${season.toString()}X${episode.toString()}`)
|
||||
);
|
||||
}) || videoFiles[0]
|
||||
: videoFiles[0];
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const extension = file.name.split('.').pop();
|
||||
const contentType = extension ? getContentType(extension) : undefined;
|
||||
console.log(
|
||||
'serving file',
|
||||
file.name,
|
||||
'with content type',
|
||||
contentType,
|
||||
file.length,
|
||||
);
|
||||
|
||||
const range = req.headers.range;
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : file.length - 1;
|
||||
const chunksize = end - start + 1;
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${file.length}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
...(contentType ? { 'Content-Type': contentType } : {}),
|
||||
});
|
||||
file.createReadStream({ start, end }).pipe(res);
|
||||
} else if (extension === 'srt') {
|
||||
res.setHeader('Content-Type', 'text/vtt');
|
||||
|
||||
const srt = await new Promise<string>(async (resolve, reject) => {
|
||||
const stream = await file.createReadStream();
|
||||
let body = '';
|
||||
stream.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
stream.on('end', () => {
|
||||
resolve(body);
|
||||
});
|
||||
stream.on('error', (err: any) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
res.send(srt2webvtt(srt));
|
||||
} else {
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
if (contentType) {
|
||||
res.setHeader('Content-Type', contentType);
|
||||
}
|
||||
res.setHeader('Content-Length', file.length);
|
||||
file.createReadStream().pipe(res);
|
||||
}
|
||||
|
||||
// res.setHeader('Accept-Ranges', 'bytes');
|
||||
// res.setHeader('Content-Type', 'video/' + extension);
|
||||
// res.setHeader('Content-Length', file.length);
|
||||
// file.createReadStream().pipe(res);
|
||||
} else {
|
||||
res.status(404).send('No file found');
|
||||
}
|
||||
};
|
||||
}
|
||||
6
torrent-stream.plugin/src/types.ts
Normal file
6
torrent-stream.plugin/src/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { SourceProviderSettings } from '@aleksilassila/reiverr-shared';
|
||||
|
||||
export interface TorrentSettings extends SourceProviderSettings {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
212
torrent-stream.plugin/src/utils.ts
Normal file
212
torrent-stream.plugin/src/utils.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
export const EPISODE_SEPARATOR = ':EPISODE:';
|
||||
|
||||
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 function formatBitrate(bitrate: number) {
|
||||
if (bitrate > 1_000_000) {
|
||||
const mbps = bitrate / 1_000_000;
|
||||
return `${mbps.toFixed(2)} Mbps`;
|
||||
} else {
|
||||
const kbps = bitrate / 1_000;
|
||||
return `${kbps.toFixed(2)} Kbps`;
|
||||
}
|
||||
}
|
||||
|
||||
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: number }[],
|
||||
|
||||
bitrate: number,
|
||||
) {
|
||||
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'
|
||||
}`;
|
||||
}
|
||||
|
||||
export const URL_REGEX =
|
||||
'https?://(www.)?[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}([-a-zA-Z0-9()@:%_+.~#?&//=]*)';
|
||||
|
||||
export const videoExtensions = [
|
||||
'.mp4',
|
||||
'.mkv',
|
||||
'.avi',
|
||||
'.webm',
|
||||
'.mov',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
'.m4v',
|
||||
];
|
||||
|
||||
export const subtitleExtensions = ['.srt', '.vtt'];
|
||||
|
||||
export function getContentType(extension: string): string | undefined {
|
||||
switch (extension) {
|
||||
case 'mp4':
|
||||
return 'video/mp4';
|
||||
case 'mkv':
|
||||
return 'video/x-matroska';
|
||||
case 'srt':
|
||||
return 'text/vtt';
|
||||
case 'vtt':
|
||||
return 'text/vtt';
|
||||
}
|
||||
|
||||
console.log('unknown extension', extension);
|
||||
}
|
||||
|
||||
export function srt2webvtt(data: string) {
|
||||
// remove dos newlines
|
||||
let srt = data.replace(/\r+/g, '');
|
||||
// trim white space start and end
|
||||
srt = srt.replace(/^\s+|\s+$/g, '');
|
||||
// get cues
|
||||
const cuelist = srt.split('\n\n');
|
||||
let result = '';
|
||||
if (cuelist.length > 0) {
|
||||
result += 'WEBVTT\n\n';
|
||||
for (let i = 0; i < cuelist.length; i = i + 1) {
|
||||
result += convertSrtCue(cuelist[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function convertSrtCue(caption: string) {
|
||||
// remove all html tags for security reasons
|
||||
//srt = srt.replace(/<[a-zA-Z\/][^>]*>/g, '');
|
||||
let cue = '';
|
||||
const s = caption.split(/\n/);
|
||||
// concatenate muilt-line string separated in array into one
|
||||
while (s.length > 3) {
|
||||
for (let i = 3; i < s.length; i++) {
|
||||
s[2] += '\n' + s[i];
|
||||
}
|
||||
s.splice(3, s.length - 3);
|
||||
}
|
||||
let line = 0;
|
||||
// detect identifier
|
||||
if (!s[0].match(/\d+:\d+:\d+/) && s[1].match(/\d+:\d+:\d+/)) {
|
||||
cue += s[0].match(/\w+/) + '\n';
|
||||
line += 1;
|
||||
}
|
||||
// get time strings
|
||||
if (s[line].match(/\d+:\d+:\d+/)) {
|
||||
// convert time string
|
||||
const m = s[1].match(
|
||||
/(\d+):(\d+):(\d+)(?:,(\d+))?\s*--?>\s*(\d+):(\d+):(\d+)(?:,(\d+))?/,
|
||||
);
|
||||
if (m) {
|
||||
cue +=
|
||||
m[1] +
|
||||
':' +
|
||||
m[2] +
|
||||
':' +
|
||||
m[3] +
|
||||
'.' +
|
||||
m[4] +
|
||||
' --> ' +
|
||||
m[5] +
|
||||
':' +
|
||||
m[6] +
|
||||
':' +
|
||||
m[7] +
|
||||
'.' +
|
||||
m[8] +
|
||||
'\n';
|
||||
line += 1;
|
||||
} else {
|
||||
// Unrecognized timestring
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
// file format error or comment lines
|
||||
return '';
|
||||
}
|
||||
// get cue text
|
||||
if (s[line]) {
|
||||
cue += s[line] + '\n\n';
|
||||
}
|
||||
return cue;
|
||||
}
|
||||
Reference in New Issue
Block a user