feat: overhaul dockerfile

This commit is contained in:
Aleksi Lassila
2025-08-09 15:36:59 +03:00
parent d831c59647
commit 5dbd6bf513
43 changed files with 2196 additions and 21014 deletions

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

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

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

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

View File

@@ -0,0 +1,6 @@
import type { SourceProviderSettings } from '@aleksilassila/reiverr-shared';
export interface TorrentSettings extends SourceProviderSettings {
apiKey: string;
baseUrl: string;
}

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