mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(web-backend): add self-hosted proxy app
This commit is contained in:
1 parent
d24c77a143
commit
f0af34edef
11 files changed
+723
No files matched your search
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
displayName: 'web-backend',
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/web-backend',
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "web-backend",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/web-backend/src",
|
||||
"tags": ["scope:app", "domain:web", "type:app"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/esbuild:esbuild",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/web-backend",
|
||||
"main": "apps/web-backend/src/main.ts",
|
||||
"tsConfig": "apps/web-backend/tsconfig.app.json",
|
||||
"format": ["cjs"],
|
||||
"platform": "node",
|
||||
"target": "node22",
|
||||
"bundle": true,
|
||||
"thirdParty": true,
|
||||
"generatePackageJson": true
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"continuous": true,
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "pnpm tsx apps/web-backend/src/main.ts",
|
||||
"cwd": "{workspaceRoot}",
|
||||
"color": true,
|
||||
"env": {
|
||||
"PORT": "3333",
|
||||
"CLIENT_URL": "http://localhost:4200",
|
||||
"BACKEND_URL": "/api"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"command": "eslint apps/web-backend/**/*.ts"
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "apps/web-backend/jest.config.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { Server } from 'node:http';
|
||||
import {
|
||||
createWebBackendApp,
|
||||
WebBackendHttpClient,
|
||||
WebBackendHttpGetOptions,
|
||||
} from './web-backend-app';
|
||||
|
||||
interface HttpRequest {
|
||||
readonly headers?: Record<string, string>;
|
||||
readonly params?: Record<string, string>;
|
||||
readonly responseData: unknown;
|
||||
readonly responseStatus?: number;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
class StubHttpClient implements WebBackendHttpClient {
|
||||
readonly requests: Omit<HttpRequest, 'responseData' | 'responseStatus'>[] =
|
||||
[];
|
||||
private readonly queuedResponses: Array<{
|
||||
readonly data: unknown;
|
||||
readonly status?: number;
|
||||
readonly statusText?: string;
|
||||
}> = [];
|
||||
|
||||
queueResponse(data: unknown): void {
|
||||
this.queuedResponses.push({ data });
|
||||
}
|
||||
|
||||
queueFailure(status: number, statusText = 'Provider failure'): void {
|
||||
this.queuedResponses.push({ data: null, status, statusText });
|
||||
}
|
||||
|
||||
async get<T>(
|
||||
url: string,
|
||||
options: WebBackendHttpGetOptions = {}
|
||||
): Promise<{ data: T }> {
|
||||
this.requests.push({
|
||||
headers: options.headers,
|
||||
params: options.params,
|
||||
url,
|
||||
});
|
||||
|
||||
const response = this.queuedResponses.shift();
|
||||
if (!response) {
|
||||
throw new Error(`No queued response for ${url}`);
|
||||
}
|
||||
|
||||
if (response.status) {
|
||||
const error = new Error(response.statusText) as Error & {
|
||||
response: { status: number; statusText: string };
|
||||
};
|
||||
error.response = {
|
||||
status: response.status,
|
||||
statusText: response.statusText ?? 'Provider failure',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { data: response.data as T };
|
||||
}
|
||||
}
|
||||
|
||||
async function withServer<T>(
|
||||
app: ReturnType<typeof createWebBackendApp>,
|
||||
callback: (baseUrl: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const server = await new Promise<Server>((resolve) => {
|
||||
const started = app.listen(0, '127.0.0.1', () => resolve(started));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address() as AddressInfo;
|
||||
return await callback(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('web backend app', () => {
|
||||
it('exposes a health endpoint', async () => {
|
||||
await withServer(createWebBackendApp(), async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/health`);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
status: 'ok',
|
||||
service: 'iptvnator-web-backend',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('serves runtime config as executable JavaScript', async () => {
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
runtimeBackendUrl: '/api',
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/config.js`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.headers.get('content-type')).toContain(
|
||||
'application/javascript'
|
||||
);
|
||||
expect(body).toContain('window.__IPTVNATOR_CONFIG__');
|
||||
expect(body).toContain('"BACKEND_URL":"/api"');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('parses remote M3U playlists into the PWA playlist shape', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse(`#EXTM3U
|
||||
#EXTINF:-1 tvg-id="news" group-title="News",News Channel
|
||||
https://stream.example/news.m3u8`);
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
clientOrigins: ['http://localhost:4200'],
|
||||
guid: () => 'fixed-id',
|
||||
httpClient,
|
||||
now: () => new Date('2026-05-15T08:00:00.000Z'),
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}`,
|
||||
{ headers: { Origin: 'http://localhost:4200' } }
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
playlist: { items: Array<Record<string, unknown>> };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(
|
||||
'http://localhost:4200'
|
||||
);
|
||||
expect(body).toMatchObject({
|
||||
_id: 'fixed-id',
|
||||
autoRefresh: false,
|
||||
count: 1,
|
||||
favorites: [],
|
||||
filename: 'list.m3u',
|
||||
id: 'fixed-id',
|
||||
importDate: '2026-05-15T08:00:00.000Z',
|
||||
lastUsage: '2026-05-15T08:00:00.000Z',
|
||||
title: 'list.m3u',
|
||||
url: 'https://provider.example/list.m3u',
|
||||
});
|
||||
expect(body.playlist.items).toHaveLength(1);
|
||||
expect(body.playlist.items[0]).toMatchObject({
|
||||
id: 'fixed-id',
|
||||
name: 'News Channel',
|
||||
url: 'https://stream.example/news.m3u8',
|
||||
});
|
||||
expect(httpClient.requests).toEqual([
|
||||
{
|
||||
headers: undefined,
|
||||
params: undefined,
|
||||
url: 'https://provider.example/list.m3u',
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies Xtream requests through the provider player API endpoint', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ user_info: { username: 'demo' } });
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({ httpClient }),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&username=demo&password=secret&action=get_account_info`
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
action: 'get_account_info',
|
||||
payload: { user_info: { username: 'demo' } },
|
||||
});
|
||||
expect(httpClient.requests).toEqual([
|
||||
{
|
||||
headers: undefined,
|
||||
params: {
|
||||
action: 'get_account_info',
|
||||
password: 'secret',
|
||||
username: 'demo',
|
||||
},
|
||||
url: 'http://xtream.example/player_api.php',
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies Stalker requests with MAC cookie and bearer token', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ js: [{ id: '2001', title: 'Action' }] });
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({ httpClient }),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/stalker?url=${encodeURIComponent('http://stalker.example/portal.php')}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod`
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
action: 'get_categories',
|
||||
payload: { js: [{ id: '2001', title: 'Action' }] },
|
||||
});
|
||||
expect(httpClient.requests).toEqual([
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Bearer abc123',
|
||||
Cookie: 'mac=00:1A:79:00:00:01',
|
||||
},
|
||||
params: {
|
||||
action: 'get_categories',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
token: 'abc123',
|
||||
type: 'vod',
|
||||
},
|
||||
url: 'http://stalker.example/portal.php',
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes provider errors for portal proxy calls', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueFailure(403, 'Forbidden');
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({ httpClient }),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&action=get_account_info`
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
message: 'Forbidden',
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import cors from 'cors';
|
||||
import express, { Express, Request } from 'express';
|
||||
import https from 'node:https';
|
||||
import zlib from 'node:zlib';
|
||||
import axios from 'axios';
|
||||
import epgParser from 'epg-parser';
|
||||
import parser from 'iptv-playlist-parser';
|
||||
|
||||
export interface WebBackendHttpGetOptions {
|
||||
readonly headers?: Record<string, string>;
|
||||
readonly httpsAgent?: unknown;
|
||||
readonly params?: Record<string, string>;
|
||||
readonly responseType?: 'arraybuffer';
|
||||
}
|
||||
|
||||
export interface WebBackendHttpClient {
|
||||
get<T>(
|
||||
url: string,
|
||||
options?: WebBackendHttpGetOptions
|
||||
): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
interface ProviderError extends Error {
|
||||
readonly response?: {
|
||||
readonly status?: number;
|
||||
readonly statusText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PlaylistParseError {
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
}
|
||||
|
||||
export interface WebBackendAppOptions {
|
||||
readonly clientOrigins?: string[];
|
||||
readonly guid?: () => string;
|
||||
readonly httpClient?: WebBackendHttpClient;
|
||||
readonly now?: () => Date;
|
||||
readonly runtimeBackendUrl?: string;
|
||||
}
|
||||
|
||||
const defaultHttpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
export function createWebBackendApp(
|
||||
options: WebBackendAppOptions = {}
|
||||
): Express {
|
||||
const app = express();
|
||||
const httpClient = (options.httpClient ?? axios) as WebBackendHttpClient;
|
||||
const guid = options.guid ?? createGuid;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const clientOrigins = options.clientOrigins ?? getClientOrigins();
|
||||
const runtimeBackendUrl =
|
||||
options.runtimeBackendUrl ?? process.env['BACKEND_URL'] ?? '/api';
|
||||
|
||||
const corsMiddleware = cors({
|
||||
origin(origin, callback) {
|
||||
if (
|
||||
!origin ||
|
||||
clientOrigins.includes('*') ||
|
||||
clientOrigins.includes(origin)
|
||||
) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(null, false);
|
||||
},
|
||||
optionsSuccessStatus: 200,
|
||||
});
|
||||
|
||||
app.get('/', (_req, res) => res.send('IPTVnator web backend'));
|
||||
app.get('/health', (_req, res) =>
|
||||
res.json({ status: 'ok', service: 'iptvnator-web-backend' })
|
||||
);
|
||||
|
||||
app.get('/config.js', corsMiddleware, (_req, res) => {
|
||||
const config = JSON.stringify({ BACKEND_URL: runtimeBackendUrl });
|
||||
res.type('application/javascript').send(
|
||||
`window.__IPTVNATOR_CONFIG__ = Object.assign({}, window.__IPTVNATOR_CONFIG__, ${config});\n`
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/parse', corsMiddleware, async (req, res) => {
|
||||
const url = getQueryString(req, 'url');
|
||||
if (!url) {
|
||||
res.status(400).send('Missing url');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await handlePlaylistParse({
|
||||
guid,
|
||||
httpClient,
|
||||
now,
|
||||
url,
|
||||
});
|
||||
|
||||
if (isPlaylistParseError(result)) {
|
||||
res.status(result.status).send(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.get('/parse-xml', corsMiddleware, async (req, res) => {
|
||||
const url = getQueryString(req, 'url');
|
||||
if (!url) {
|
||||
res.status(400).send('Missing url');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchEpgDataFromUrl(httpClient, url);
|
||||
if (!result) {
|
||||
res.status(500).send('Error, something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.get('/xtream', corsMiddleware, async (req, res) => {
|
||||
const url = getQueryString(req, 'url');
|
||||
if (!url) {
|
||||
res.status(400).json({ message: 'Missing url', status: 400 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.get(
|
||||
`${trimTrailingSlash(url)}/player_api.php`,
|
||||
{
|
||||
params: getProxyParams(req, ['url']),
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
action: getQueryString(req, 'action'),
|
||||
payload: response.data,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json(normalizeProviderError(error));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/stalker', corsMiddleware, async (req, res) => {
|
||||
const url = getQueryString(req, 'url');
|
||||
const macAddress = getQueryString(req, 'macAddress');
|
||||
const token = getQueryString(req, 'token');
|
||||
if (!url) {
|
||||
res.status(400).json({ message: 'Missing url', status: 400 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.get(url, {
|
||||
params: getProxyParams(req, ['url']),
|
||||
headers: {
|
||||
...(macAddress ? { Cookie: `mac=${macAddress}` } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
action: getQueryString(req, 'action'),
|
||||
payload: response.data,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json(normalizeProviderError(error));
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function getClientOrigins(): string[] {
|
||||
const configured = process.env['CLIENT_URL']
|
||||
?.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (configured?.length) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return process.env['NODE_ENV'] === 'development' ||
|
||||
process.env['NODE_ENV'] === 'dev'
|
||||
? ['http://localhost:4200']
|
||||
: ['https://iptvnator.vercel.app'];
|
||||
}
|
||||
|
||||
function getQueryString(req: Request, key: string): string | undefined {
|
||||
const value = req.query[key];
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeQueryValue(value[0]);
|
||||
}
|
||||
return normalizeQueryValue(value);
|
||||
}
|
||||
|
||||
function normalizeQueryValue(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function getProxyParams(
|
||||
req: Request,
|
||||
excludedKeys: string[]
|
||||
): Record<string, string> {
|
||||
const excluded = new Set(excludedKeys);
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (excluded.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = Array.isArray(value)
|
||||
? normalizeQueryValue(value[0])
|
||||
: normalizeQueryValue(value);
|
||||
if (normalized) {
|
||||
params[key] = normalized;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function handlePlaylistParse(options: {
|
||||
readonly guid: () => string;
|
||||
readonly httpClient: WebBackendHttpClient;
|
||||
readonly now: () => Date;
|
||||
readonly url: string;
|
||||
}): Promise<Record<string, unknown> | PlaylistParseError> {
|
||||
try {
|
||||
const response = await options.httpClient.get<string>(options.url, {
|
||||
httpsAgent: defaultHttpsAgent,
|
||||
});
|
||||
const parsedPlaylist = parsePlaylist(response.data);
|
||||
const title = getLastUrlSegment(options.url);
|
||||
return createPlaylistObject({
|
||||
guid: options.guid,
|
||||
now: options.now,
|
||||
playlist: parsedPlaylist,
|
||||
title,
|
||||
url: options.url,
|
||||
});
|
||||
} catch (error) {
|
||||
const providerError = error as ProviderError;
|
||||
return {
|
||||
status: providerError.response?.status ?? 500,
|
||||
message:
|
||||
providerError.response?.statusText ??
|
||||
'Error, something went wrong',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEpgDataFromUrl(
|
||||
httpClient: WebBackendHttpClient,
|
||||
url: string
|
||||
): Promise<unknown> {
|
||||
const response = await httpClient.get<ArrayBuffer | string>(url.trim(), {
|
||||
...(url.endsWith('.gz') ? { responseType: 'arraybuffer' } : {}),
|
||||
});
|
||||
const xml = url.endsWith('.gz')
|
||||
? zlib.gunzipSync(Buffer.from(response.data as ArrayBuffer)).toString()
|
||||
: response.data.toString();
|
||||
return epgParser.parse(xml);
|
||||
}
|
||||
|
||||
function isPlaylistParseError(
|
||||
result: Record<string, unknown> | PlaylistParseError
|
||||
): result is PlaylistParseError {
|
||||
return (
|
||||
typeof (result as PlaylistParseError).status === 'number' &&
|
||||
typeof (result as PlaylistParseError).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function parsePlaylist(
|
||||
playlist: string
|
||||
): { items: Array<Record<string, unknown>> } {
|
||||
return parser.parse(playlist) as unknown as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
function createPlaylistObject(options: {
|
||||
readonly guid: () => string;
|
||||
readonly now: () => Date;
|
||||
readonly playlist: { items: Array<Record<string, unknown>> };
|
||||
readonly title: string;
|
||||
readonly url: string;
|
||||
}): Record<string, unknown> {
|
||||
const timestamp = options.now().toISOString();
|
||||
return {
|
||||
id: options.guid(),
|
||||
_id: options.guid(),
|
||||
filename: options.title,
|
||||
title: options.title,
|
||||
count: options.playlist.items.length,
|
||||
playlist: {
|
||||
...options.playlist,
|
||||
items: options.playlist.items.map((item) => ({
|
||||
id: options.guid(),
|
||||
...item,
|
||||
})),
|
||||
},
|
||||
importDate: timestamp,
|
||||
lastUsage: timestamp,
|
||||
favorites: [],
|
||||
autoRefresh: false,
|
||||
url: options.url,
|
||||
};
|
||||
}
|
||||
|
||||
function getLastUrlSegment(value: string): string {
|
||||
const segment = value.slice(value.lastIndexOf('/') + 1).trim();
|
||||
return segment.length > 0 ? segment : 'Playlist without title';
|
||||
}
|
||||
|
||||
function normalizeProviderError(error: unknown): {
|
||||
readonly message: string;
|
||||
readonly status: number;
|
||||
} {
|
||||
const providerError = error as ProviderError;
|
||||
return {
|
||||
message: providerError.response?.statusText ?? 'Error: not found',
|
||||
status: providerError.response?.status ?? 404,
|
||||
};
|
||||
}
|
||||
|
||||
function createGuid(): string {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createWebBackendApp } from './app/web-backend-app';
|
||||
|
||||
const port = Number(process.env['PORT'] ?? 3000);
|
||||
const app = createWebBackendApp();
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`IPTVnator web backend listening on http://localhost:${port}`);
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
declare module 'epg-parser' {
|
||||
const parser: {
|
||||
parse(xml: string): unknown;
|
||||
};
|
||||
|
||||
export default parser;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -86,6 +86,7 @@
|
||||
"drizzle-orm": "0.45.2",
|
||||
"electron-conf": "1.3.0",
|
||||
"electron-dl": "4.0.0",
|
||||
"epg-parser": "^0.1.6",
|
||||
"fix-path": "5.0.0",
|
||||
"hls.js": "1.6.13",
|
||||
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
|
||||
|
||||
Generated
+18
@@ -118,6 +118,9 @@ importers:
|
||||
electron-dl:
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
epg-parser:
|
||||
specifier: ^0.1.6
|
||||
version: 0.1.6
|
||||
fix-path:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
@@ -6577,6 +6580,9 @@ packages:
|
||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
epg-parser@0.1.6:
|
||||
resolution: {integrity: sha512-g6AxKOvs0E4bTGPdIUh8/FDKdrVjbf4DVK0jIFuChDt7wBRJmMVyqbLeS8NApf6M2wpCRLBpIenXOCS88w0Rqw==}
|
||||
|
||||
err-code@2.0.3:
|
||||
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
|
||||
|
||||
@@ -12023,6 +12029,10 @@ packages:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xml-js@1.6.11:
|
||||
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
||||
hasBin: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -19160,6 +19170,10 @@ snapshots:
|
||||
|
||||
environment@1.1.0: {}
|
||||
|
||||
epg-parser@0.1.6:
|
||||
dependencies:
|
||||
xml-js: 1.6.11
|
||||
|
||||
err-code@2.0.3: {}
|
||||
|
||||
errno@0.1.8:
|
||||
@@ -25950,6 +25964,10 @@ snapshots:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
xml-js@1.6.11:
|
||||
dependencies:
|
||||
sax: 1.6.0
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
Reference in new issue
Block a user