mirror of
https://github.com/TheBinaryNinja/tvapp2.git
synced 2026-06-04 06:45:41 -04:00
975 lines
29 KiB
JavaScript
Executable File
975 lines
29 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/*
|
|
Import Packages
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import https from 'https';
|
|
import path from 'path';
|
|
import http from 'http';
|
|
import zlib from 'zlib';
|
|
import chalk from 'chalk';
|
|
import ejs from 'ejs';
|
|
import * as tar from 'tar';
|
|
|
|
/*
|
|
Old CJS variables converted to ESM
|
|
*/
|
|
|
|
import { fileURLToPath } from 'url';
|
|
const cache = new Map();
|
|
|
|
/*
|
|
Import package.json values
|
|
*/
|
|
|
|
const { name, author, version, repository } = JSON.parse( fs.readFileSync( './package.json' ) );
|
|
const __filename = fileURLToPath( import.meta.url ); // get resolved path to file
|
|
const __dirname = path.dirname( __filename ); // get name of directory
|
|
|
|
/*
|
|
chalk.level
|
|
|
|
@ref https://npmjs.com/package/chalk
|
|
- 0 All colors disabled
|
|
- 1 Basic color support (16 colors)
|
|
- 2 256 color support
|
|
- 3 Truecolor support (16 million colors)
|
|
|
|
When assigning text colors, terminals and the windows command prompt can display any color; however apps
|
|
such as Portainer console cannot. If you use 16 million colors and are viewing console in Portainer, colors will
|
|
not be the same as the rgb value. It's best to just stick to Chalk's default colors.
|
|
*/
|
|
|
|
chalk.level = 3;
|
|
|
|
/*
|
|
Define > General
|
|
*/
|
|
|
|
let FILE_URL;
|
|
let FILE_DAT;
|
|
let FILE_XML;
|
|
let FILE_TAR;
|
|
|
|
/*
|
|
Define > Environment Variables || Defaults
|
|
*/
|
|
|
|
const envUrlRepo = process.env.URL_REPO || `https://git.binaryninja.net/binaryninja`;
|
|
const envStreamQuality = process.env.STREAM_QUALITY || `hd`;
|
|
const envFileM3U = process.env.FILE_PLAYLIST || `playlist.m3u8`;
|
|
const envFileXML = process.env.FILE_EPG || `xmltv.xml`;
|
|
const envFileTAR = process.env.FILE_TAR || `xmltv.tar.gz`;
|
|
const LOG_LEVEL = process.env.LOG_LEVEL || 10;
|
|
|
|
/*
|
|
Define > Externals
|
|
*/
|
|
|
|
const extURL = `${ envUrlRepo }/tvapp2-externals/raw/branch/main/urls.txt`;
|
|
const extXML = `${ envUrlRepo }/XMLTV-EPG/raw/branch/main/xmltv.1.xml`;
|
|
const extFormatted = `${ envUrlRepo }/tvapp2-externals/raw/branch/main/formatted.dat`;
|
|
|
|
/*
|
|
Define > Defaults
|
|
*/
|
|
|
|
let urls = [];
|
|
const gCookies = {};
|
|
const USERAGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
|
|
/*
|
|
Define > Logs
|
|
|
|
When assigning text colors, terminals and the windows command prompt can display any color; however apps
|
|
such as Portainer console cannot. If you use 16 million colors and are viewing console in Portainer, colors will
|
|
not be the same as the rgb value. It's best to just stick to Chalk's default colors.
|
|
|
|
Various levels of logs with the following usage:
|
|
Log.trace(`This is trace`)
|
|
Log.debug(`This is debug`)
|
|
Log.info(`This is info`)
|
|
Log.ok(`This is ok`)
|
|
Log.notice(`This is notice`)
|
|
Log.warn(`This is warn`)
|
|
Log.error(
|
|
`Error fetching sports data with error:`,
|
|
chalk.white(` → `),
|
|
chalk.grey(`This is the error message`)
|
|
);
|
|
|
|
Level Type
|
|
-----------------------------------
|
|
6 Trace
|
|
5 Debug
|
|
4 Info
|
|
3 Notice
|
|
2 Warn
|
|
1 Error
|
|
*/
|
|
|
|
class Log
|
|
{
|
|
static now()
|
|
{
|
|
const now = new Date();
|
|
return chalk.gray( `[${ now.toLocaleTimeString() }]` );
|
|
}
|
|
|
|
static trace( ...msg )
|
|
{
|
|
if ( LOG_LEVEL >= 6 )
|
|
console.trace( chalk.white.bgMagenta.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.magentaBright( msg.join( ' ' ) ) );
|
|
}
|
|
|
|
static debug( ...msg )
|
|
{
|
|
if ( LOG_LEVEL >= 5 )
|
|
console.debug( chalk.white.bgGray.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.gray( msg.join( ' ' ) ) );
|
|
}
|
|
|
|
static info( ...msg )
|
|
{
|
|
if ( LOG_LEVEL >= 4 )
|
|
console.info( chalk.white.bgBlueBright.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.blueBright( msg.join( ' ' ) ) );
|
|
}
|
|
|
|
static ok( ...msg )
|
|
{
|
|
if ( LOG_LEVEL >= 4 )
|
|
console.log( chalk.white.bgGreen.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.greenBright( msg.join( ' ' ) ) );
|
|
}
|
|
|
|
static notice( ...msg )
|
|
{
|
|
if ( LOG_LEVEL >= 3 )
|
|
console.log( chalk.white.bgYellow.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.yellowBright( msg.join( ' ' ) ) );
|
|
}
|
|
|
|
static warn( ...msg )
|
|
{
|
|
if ( LOG_LEVEL >= 2 )
|
|
console.warn( chalk.white.bgYellow.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.yellow( msg.join( ' ' ) ) );
|
|
}
|
|
|
|
static error( ...msg )
|
|
{
|
|
if ( LOG_LEVEL >= 1 )
|
|
console.error( chalk.white.bgRedBright.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.red( msg.join( ' ' ) ) );
|
|
}
|
|
}
|
|
|
|
/*
|
|
Process
|
|
*/
|
|
|
|
if ( process.pkg )
|
|
{
|
|
Log.info( `Processing Package` );
|
|
const basePath = path.dirname( process.execPath );
|
|
FILE_URL = path.join( basePath, 'urls.txt' );
|
|
FILE_DAT = path.join( basePath, 'formatted.dat' );
|
|
FILE_XML = path.join( basePath, `${ envFileXML }` );
|
|
FILE_XML.length;
|
|
FILE_TAR = path.join( basePath, `${ envFileTAR }` );
|
|
}
|
|
else
|
|
{
|
|
Log.info( `Processing Locals` );
|
|
FILE_URL = path.resolve( __dirname, 'urls.txt' );
|
|
FILE_DAT = path.resolve( __dirname, 'formatted.dat' );
|
|
FILE_XML = path.resolve( __dirname, `${ envFileXML }` );
|
|
FILE_TAR = path.resolve( __dirname, `${ envFileTAR }` );
|
|
}
|
|
|
|
/*
|
|
Semaphore > Declare
|
|
|
|
allows multiple threads to work with the same shared resources
|
|
*/
|
|
|
|
class Semaphore
|
|
{
|
|
constructor( max )
|
|
{
|
|
this.max = max;
|
|
this.queue = [];
|
|
this.active = 0;
|
|
}
|
|
async acquire()
|
|
{
|
|
if ( this.active < this.max )
|
|
{
|
|
this.active++;
|
|
return;
|
|
}
|
|
return new Promise( ( resolve ) => this.queue.push( resolve ) );
|
|
}
|
|
release()
|
|
{
|
|
this.active--;
|
|
if ( this.queue.length > 0 )
|
|
{
|
|
const resolve = this.queue.shift();
|
|
this.active++;
|
|
resolve();
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
Semaphore > Initialize
|
|
|
|
@arg int threads_max
|
|
*/
|
|
|
|
const semaphore = new Semaphore( 5 );
|
|
|
|
/*
|
|
Func > Download File
|
|
|
|
@arg str url https://git.binaryninja.net/binaryninja/tvapp2-externals/raw/branch/main/urls.txt
|
|
@arg str filePath H:\Repos\github\BinaryNinja\tvapp2\tvapp2\urls.txt
|
|
@return Promise<>
|
|
*/
|
|
|
|
async function downloadFile( url, filePath )
|
|
{
|
|
Log.info( `Fetching ${ url }` );
|
|
|
|
return new Promise( ( resolve, reject ) =>
|
|
{
|
|
const isHttps = new URL( url ).protocol === 'https:';
|
|
const httpModule = isHttps ? https : http;
|
|
const file = fs.createWriteStream( filePath );
|
|
httpModule
|
|
.get( url, ( response ) =>
|
|
{
|
|
if ( response.statusCode !== 200 )
|
|
{
|
|
Log.error( `Failed to download file: ${ url }`, chalk.white( ` → ` ), chalk.grey( `Status code: ${ response.statusCode }` ) );
|
|
return reject( new Error( `Failed to download file: ${ url }. Status code: ${ response.statusCode }` ) );
|
|
}
|
|
response.pipe( file );
|
|
file.on( 'finish', () =>
|
|
{
|
|
Log.ok( `Successfully fetched ${ filePath }` );
|
|
file.close( () => resolve( true ) );
|
|
});
|
|
})
|
|
.on( 'error', ( err ) =>
|
|
{
|
|
Log.error( `Error downloading file: ${ url }`, chalk.white( ` → ` ), chalk.grey( `Status code: ${ err.message }` ) );
|
|
fs.unlink( filePath, () => reject( err ) );
|
|
});
|
|
});
|
|
}
|
|
|
|
/*
|
|
Func > Ensure File Exists
|
|
|
|
if file exists; start download from external website utilizing url and file path arguments; or
|
|
throw error to user that file does not exist via the URL.
|
|
|
|
If file cannot be obtained from external url; use local copy if available
|
|
|
|
@arg str url https://git.binaryninja.net/binaryninja/tvapp2-externals/raw/branch/main/urls.txt
|
|
@arg str filePath H:\Repos\github\BinaryNinja\tvapp2\tvapp2\urls.txt
|
|
@return none
|
|
*/
|
|
|
|
async function ensureFileExists( url, filePath )
|
|
{
|
|
try
|
|
{
|
|
await downloadFile( url, filePath );
|
|
}
|
|
catch ( error )
|
|
{
|
|
if ( fs.existsSync( filePath ) )
|
|
{
|
|
Log.warn( `Using existing local file ${ filePath }, download failed`, chalk.white( ` → ` ), chalk.grey( `${ url }` ) );
|
|
}
|
|
else
|
|
{
|
|
Log.error( `Failed to download file, and no local file exists; aborting`, chalk.white( ` → ` ), chalk.grey( `${ url }` ) );
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchRemote( url )
|
|
{
|
|
return new Promise( ( resolve, reject ) =>
|
|
{
|
|
const mod = url.startsWith( 'https' ) ? https : http;
|
|
mod
|
|
.get( url, {
|
|
headers: {
|
|
'Accept-Encoding': 'gzip, deflate, br'
|
|
}
|
|
}, ( resp ) =>
|
|
{
|
|
if ( resp.statusCode !== 200 )
|
|
{
|
|
Log.error( `Server returned status code other than 200`, chalk.white( ` → ` ), chalk.grey( `${ url } - ${ resp.statusCode }` ) );
|
|
return reject( new Error( `HTTP ${ resp.statusCode } for ${ url }` ) );
|
|
}
|
|
|
|
const chunks = [];
|
|
|
|
resp.on( 'data', ( chunk ) => chunks.push( chunk ) );
|
|
resp.on( 'end', () =>
|
|
{
|
|
const buffer = Buffer.concat( chunks );
|
|
const encoding = resp.headers['content-encoding'];
|
|
|
|
if ( encoding === 'gzip' )
|
|
{
|
|
zlib.gunzip( buffer, ( err, decoded ) =>
|
|
{
|
|
if ( err ) return reject( err );
|
|
resolve( decoded );
|
|
});
|
|
}
|
|
else if ( encoding === 'deflate' )
|
|
{
|
|
zlib.inflate( buffer, ( err, decoded ) =>
|
|
{
|
|
if ( err ) return reject( err );
|
|
resolve( decoded );
|
|
});
|
|
}
|
|
else if ( encoding === 'br' )
|
|
{
|
|
zlib.brotliDecompress( buffer, ( err, decoded ) =>
|
|
{
|
|
if ( err ) return reject( err );
|
|
resolve( decoded );
|
|
});
|
|
}
|
|
else
|
|
{
|
|
resolve( buffer );
|
|
}
|
|
});
|
|
})
|
|
.on( 'error', reject );
|
|
});
|
|
}
|
|
|
|
async function serveKey( req, res )
|
|
{
|
|
try
|
|
{
|
|
const uriParam = new URL( req.url, `http://${ req.headers.host }` ).searchParams.get( 'uri' );
|
|
if ( !uriParam )
|
|
{
|
|
res.writeHead( 400, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
Log.error( `Missing "uri" parameter for key download`, chalk.white( ` → ` ), chalk.grey( `${ req.url }` ) );
|
|
|
|
return res.end( 'Error: Missing "uri" parameter for key download.' );
|
|
}
|
|
|
|
const keyData = await fetchRemote( uriParam );
|
|
res.writeHead( 200, {
|
|
'Content-Type': 'application/octet-stream'
|
|
});
|
|
|
|
res.end( keyData );
|
|
}
|
|
catch ( err )
|
|
{
|
|
Log.error( `ServeKey Error:`, chalk.white( ` → ` ), chalk.grey( `${ err.message }` ) );
|
|
|
|
res.writeHead( 500, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
res.end( 'Error fetching key.' );
|
|
}
|
|
}
|
|
|
|
function parseSetCookieHeaders( setCookieValues )
|
|
{
|
|
if ( !Array.isArray( setCookieValues ) ) return;
|
|
setCookieValues.forEach( ( line ) =>
|
|
{
|
|
const [cookiePair] = line.split( ';' );
|
|
if ( cookiePair )
|
|
{
|
|
const [ key, val ] = cookiePair.split( '=' );
|
|
if ( key && val )
|
|
{
|
|
gCookies[key.trim()] = val.trim();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildCookieHeader()
|
|
{
|
|
const pairs = [];
|
|
for ( const [ k, v ] of Object.entries( gCookies ) )
|
|
{
|
|
pairs.push( `${ k }=${ v }` );
|
|
}
|
|
return pairs.join( '; ' );
|
|
}
|
|
|
|
function fetchPage( url )
|
|
{
|
|
return new Promise( ( resolve, reject ) =>
|
|
{
|
|
const opts = {
|
|
method: 'GET',
|
|
headers: {
|
|
'User-Agent': USERAGENT,
|
|
Accept: '*/*',
|
|
Cookie: buildCookieHeader()
|
|
}
|
|
};
|
|
https
|
|
.get( url, opts, ( res ) =>
|
|
{
|
|
if ( res.statusCode !== 200 )
|
|
{
|
|
return reject( new Error( `Non-200 status ${ res.statusCode } => ${ url }` ) );
|
|
}
|
|
|
|
if ( res.headers['set-cookie'])
|
|
{
|
|
parseSetCookieHeaders( res.headers['set-cookie']);
|
|
}
|
|
|
|
let data = '';
|
|
res.on( 'data', ( chunk ) => ( data += chunk ) );
|
|
res.on( 'end', () => resolve( data ) );
|
|
})
|
|
.on( 'error', reject );
|
|
});
|
|
}
|
|
|
|
async function getTokenizedUrl( channelUrl )
|
|
{
|
|
try
|
|
{
|
|
const html = await fetchPage( channelUrl );
|
|
|
|
let streamName;
|
|
let streamHost;
|
|
|
|
if ( channelUrl.includes( 'espn-' ) )
|
|
{
|
|
streamName = 'ESPN';
|
|
}
|
|
else if ( channelUrl.includes( 'espn2-' ) )
|
|
{
|
|
streamName = 'ESPN2';
|
|
}
|
|
else
|
|
{
|
|
const streamNameMatch = html.match( /id="stream_name" name="([^"]+)"/ );
|
|
if ( !streamNameMatch )
|
|
{
|
|
Log.error( `Cannot find "stream_name"`, chalk.white( ` → ` ), chalk.grey( `${ channelUrl }` ) );
|
|
return null;
|
|
}
|
|
streamName = streamNameMatch[1];
|
|
}
|
|
|
|
if ( channelUrl.match( 'tvpass\.org' ) )
|
|
{
|
|
streamHost = 'tvpass.org';
|
|
};
|
|
|
|
if ( channelUrl.match( 'thetvapp\.to' ) )
|
|
{
|
|
streamHost = 'thetvapp.to';
|
|
};
|
|
|
|
const tokenUrl = `https://${ streamHost }/token/${ streamName }?quality=${ envStreamQuality }`;
|
|
const tokenResponse = await fetchPage( tokenUrl );
|
|
let finalUrl;
|
|
|
|
try
|
|
{
|
|
const json = JSON.parse( tokenResponse );
|
|
finalUrl = json.url;
|
|
}
|
|
catch ( err )
|
|
{
|
|
Log.error( `Failed to parse token JSON for channel`, chalk.white( ` → ` ), chalk.grey( `${ channelUrl } - ${ err.message }` ) );
|
|
return null;
|
|
}
|
|
|
|
if ( !finalUrl )
|
|
{
|
|
Log.error( `No URL found in token JSON for channel`, chalk.white( ` → ` ), chalk.grey( `${ channelUrl }` ) );
|
|
return null;
|
|
}
|
|
|
|
Log.debug( `Tokenized URL:`, chalk.white( ` → ` ), chalk.grey( `${ finalUrl }` ) );
|
|
|
|
return finalUrl;
|
|
}
|
|
catch ( err )
|
|
{
|
|
Log.error( `Fatal error fetching token:`, chalk.white( ` → ` ), chalk.grey( `${ err.message }` ) );
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function serveM3UPlaylist( req, res )
|
|
{
|
|
await semaphore.acquire();
|
|
try
|
|
{
|
|
const urlParam = new URL( req.url, `http://${ req.headers.host }` ).searchParams.get( 'url' );
|
|
if ( !urlParam )
|
|
{
|
|
Log.error( `Missing parameter`, chalk.white( ` → ` ), chalk.grey( `URL` ) );
|
|
res.writeHead( 400, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
res.end( 'Error: Missing URL parameter.' );
|
|
return;
|
|
}
|
|
|
|
const decodedUrl = decodeURIComponent( urlParam );
|
|
if ( decodedUrl.endsWith( '.ts' ) )
|
|
{
|
|
res.writeHead( 302, {
|
|
Location: decodedUrl
|
|
});
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const cachedUrl = getCache( decodedUrl );
|
|
if ( cachedUrl )
|
|
{
|
|
const rewrittenPlaylist = await rewriteM3U( cachedUrl, req );
|
|
res.writeHead( 200,
|
|
{
|
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
|
'Content-Disposition': 'inline; filename="' + envFileM3U
|
|
});
|
|
|
|
res.end( rewrittenPlaylist );
|
|
return;
|
|
}
|
|
|
|
Log.info( `Fetching stream:`, chalk.white( ` → ` ), chalk.grey( `${ urlParam }` ) );
|
|
|
|
const finalUrl = await getTokenizedUrl( decodedUrl );
|
|
if ( !finalUrl )
|
|
{
|
|
Log.error( `Failed to retrieve tokenized URL` );
|
|
|
|
res.writeHead( 500, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
res.end( 'Error: Failed to retrieve tokenized URL.' );
|
|
return;
|
|
}
|
|
|
|
setCache( decodedUrl, finalUrl, 4 * 60 * 60 * 1000 );
|
|
const hdUrl = finalUrl.replace( 'tracks-v2a1', 'tracks-v1a1' );
|
|
const rewrittenPlaylist = await rewriteM3U( hdUrl, req );
|
|
res.writeHead( 200, {
|
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
|
'Content-Disposition': 'inline; filename="' + envFileM3U
|
|
});
|
|
|
|
res.end( rewrittenPlaylist );
|
|
Log.ok( `Served playlist` );
|
|
}
|
|
catch ( error )
|
|
{
|
|
Log.error( `Error processing request:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) );
|
|
|
|
if ( !res.headersSent )
|
|
{
|
|
res.writeHead( 500, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
res.end( 'Error processing request.' );
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
semaphore.release();
|
|
}
|
|
}
|
|
|
|
/*
|
|
Rewrites the URLs
|
|
*/
|
|
|
|
async function rewriteM3U( originalUrl, req )
|
|
{
|
|
const rawData = await fetchRemote( originalUrl );
|
|
const protocol = req.headers['x-forwarded-proto']?.split( ',' )[0] || ( req.socket.encrypted ? 'https' : 'http' );
|
|
const host = req.headers.host;
|
|
const baseUrl = `${ protocol }://${ host }`;
|
|
const playlistContent = rawData.toString( 'utf8' );
|
|
return playlistContent
|
|
.replace( /URI="([^"]+)"/g, ( match, uri ) =>
|
|
{
|
|
const resolvedUri = new URL( uri, originalUrl ).href;
|
|
return `URI="${ baseUrl }/key?uri=${ encodeURIComponent( resolvedUri ) }"`;
|
|
})
|
|
.replace( /^([^#].*\.m3u8)(\?.*)?$/gm, ( match, uri ) =>
|
|
{
|
|
const resolvedUri = new URL( uri, originalUrl ).href;
|
|
return `${ baseUrl }/channel?url=${ encodeURIComponent( resolvedUri ) }`;
|
|
})
|
|
.replace( /^([^#].*\.ts)(\?.*)?$/gm, ( match, uri ) =>
|
|
{
|
|
const resolvedUri = new URL( uri, originalUrl ).href;
|
|
return `${ baseUrl }/channel?url=${ encodeURIComponent( resolvedUri ) }`;
|
|
});
|
|
}
|
|
|
|
/*
|
|
Serves IPTV .m3u playlist
|
|
*/
|
|
|
|
async function serveM3U( response, req )
|
|
{
|
|
try
|
|
{
|
|
const protocol = req.headers['x-forwarded-proto']?.split( ',' )[0] || ( req.socket.encrypted ? 'https' : 'http' );
|
|
const host = req.headers.host;
|
|
const baseUrl = `${ protocol }://${ host }`;
|
|
const formattedContent = fs.readFileSync( FILE_DAT, 'utf-8' );
|
|
const updatedContent = formattedContent
|
|
.replace( /(https?:\/\/[^\s]*thetvapp[^\s]*)/g, ( fullUrl ) =>
|
|
{
|
|
return `${ baseUrl }/channel?url=${ encodeURIComponent( fullUrl ) }`;
|
|
})
|
|
.replace( /(https?:\/\/[^\s]*tvpass[^\s]*)/g, ( fullUrl ) =>
|
|
{
|
|
return `${ baseUrl }/channel?url=${ encodeURIComponent( fullUrl ) }`;
|
|
});
|
|
|
|
response.writeHead( 200, {
|
|
'Content-Type': 'application/x-mpegURL',
|
|
'Content-Disposition': 'inline; filename="' + envFileM3U
|
|
});
|
|
|
|
response.end( updatedContent );
|
|
}
|
|
catch ( error )
|
|
{
|
|
Log.error( `Error in serveM3U:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) );
|
|
|
|
response.writeHead( 500, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
response.end( `Error serving playlist: ${ error.message }` );
|
|
}
|
|
}
|
|
|
|
/*
|
|
Serves IPTV .xml guide data
|
|
*/
|
|
|
|
async function serveXML( response, req )
|
|
{
|
|
try
|
|
{
|
|
const protocol = req.headers['x-forwarded-proto']?.split( ',' )[0] || ( req.socket.encrypted ? 'https' : 'http' );
|
|
const host = req.headers.host;
|
|
const baseUrl = `${ protocol }://${ host }`;
|
|
const formattedContent = fs.readFileSync( FILE_XML, 'utf-8' );
|
|
|
|
response.writeHead( 200, {
|
|
'Content-Type': 'application/xml',
|
|
'Content-Disposition': 'inline; filename="' + envFileXML
|
|
});
|
|
|
|
response.end( formattedContent );
|
|
}
|
|
catch ( error )
|
|
{
|
|
Log.error( `Error in serveM3U:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) );
|
|
|
|
response.writeHead( 500, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
response.end( `Error serving playlist: ${ error.message }` );
|
|
}
|
|
};
|
|
|
|
/*
|
|
Serves IPTV .tar.gz guide data
|
|
*/
|
|
|
|
async function serveTAR( response, req )
|
|
{
|
|
try
|
|
{
|
|
const protocol = req.headers['x-forwarded-proto']?.split( ',' )[0] || ( req.socket.encrypted ? 'https' : 'http' );
|
|
const host = req.headers.host;
|
|
const baseUrl = `${ protocol }://${ host }`;
|
|
const formattedContent = fs.readFileSync( FILE_TAR );
|
|
|
|
response.writeHead( 200, {
|
|
'Content-Type': 'application/gzip',
|
|
'Content-Disposition': 'inline; filename="' + envFileTAR
|
|
});
|
|
|
|
response.end( formattedContent );
|
|
}
|
|
catch ( error )
|
|
{
|
|
Log.error( `Error in serveTAR:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) );
|
|
|
|
response.writeHead( 500, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
response.end( `Error serving tar.gz: ${ error.message }` );
|
|
}
|
|
};
|
|
|
|
function setCache( key, value, ttl )
|
|
{
|
|
const expiry = Date.now() + ttl;
|
|
cache.set( key, {
|
|
value,
|
|
expiry
|
|
});
|
|
|
|
Log.debug( `Cache set for key ${ key } which expires in`, chalk.white( ` → ` ), chalk.grey( `${ ttl / 1000 } seconds` ) );
|
|
}
|
|
|
|
function getCache( key )
|
|
{
|
|
const cached = cache.get( key );
|
|
if ( cached && cached.expiry > Date.now() )
|
|
{
|
|
return cached.value;
|
|
}
|
|
else
|
|
{
|
|
if ( cached )
|
|
Log.debug( `Cache expired for key`, chalk.white( ` → ` ), chalk.grey( `${ key }` ) );
|
|
|
|
cache.delete( key );
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function initialize()
|
|
{
|
|
try
|
|
{
|
|
Log.info( `Initializing server...` );
|
|
|
|
await ensureFileExists( extURL, FILE_URL );
|
|
await ensureFileExists( extFormatted, FILE_DAT );
|
|
await ensureFileExists( extXML, FILE_XML );
|
|
|
|
/*
|
|
Create tar.gz of xml data
|
|
Must specify `cwd` - current working directory; without this, the absolute path to the .tar.gz file
|
|
will be added to the archive.
|
|
|
|
set cwd to the folder where the xmltv.1.xml file is located
|
|
*/
|
|
|
|
tar.c(
|
|
{
|
|
gzip: true,
|
|
cwd: path.resolve( __dirname ),
|
|
mtime: new Date(),
|
|
strict: false,
|
|
portable: true,
|
|
jobs: 2,
|
|
maxReadSize: 48 * 1024 * 1024,
|
|
onwarn: ( code, message, data ) =>
|
|
{
|
|
Log.warn( `Tar.gz warning:`, chalk.white( ` → ` ), chalk.grey( `[${ code }] ${ message }` ) );
|
|
}
|
|
},
|
|
[`${ envFileXML }`]
|
|
).pipe( fs.createWriteStream( `${ envFileTAR }` ) );
|
|
|
|
urls = fs.readFileSync( FILE_URL, 'utf-8' ).split( '\n' ).filter( Boolean );
|
|
if ( urls.length === 0 )
|
|
{
|
|
throw new Error( `No valid URLs found in ${ FILE_URL }` );
|
|
}
|
|
|
|
Log.info( `Initializing Complete` );
|
|
}
|
|
catch ( error )
|
|
{
|
|
Log.error( `Initialization error:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) );
|
|
}
|
|
}
|
|
|
|
/*
|
|
Webserver
|
|
|
|
@todo possibility of switching out http.createserver with express
|
|
|
|
import express from 'express';
|
|
const app = express()
|
|
app.use(express.static('www'));
|
|
const server = app.listen(8000, function () {
|
|
const host = server.address().address
|
|
const port = server.address().port
|
|
|
|
console.log('Express listening at http://%s:%s', host, port)
|
|
})
|
|
*/
|
|
|
|
const server = http.createServer( ( request, response ) =>
|
|
{
|
|
/*
|
|
If request.url === '/'; load index.html as default page
|
|
|
|
request.url returns
|
|
/
|
|
/www/css/tvapp2.fonts.min.css
|
|
/www/css/tvapp2.min.css
|
|
*/
|
|
|
|
const method = request.method || 'GET';
|
|
let loadAsset = request.url;
|
|
if ( loadAsset === '/' )
|
|
loadAsset = 'index.html';
|
|
|
|
Log.debug( `www`, chalk.yellow( ` [GET] ` ), chalk.white( ` → ` ), chalk.grey( `${ loadAsset }` ) );
|
|
|
|
const handleRequest = async() =>
|
|
{
|
|
/*
|
|
Define the different routes.
|
|
Place the template system last. Getting TVApp data should take priority.
|
|
*/
|
|
|
|
if ( loadAsset === '/playlist' && method === 'GET' )
|
|
{
|
|
Log.info( `Received request for playlist data`, chalk.white( ` → ` ), chalk.grey( `/playlist` ) );
|
|
|
|
await serveM3U( response, request );
|
|
return;
|
|
}
|
|
|
|
if ( loadAsset.startsWith( '/channel' ) && method === 'GET' )
|
|
{
|
|
Log.info( `Received request for channel data`, chalk.white( ` → ` ), chalk.grey( `/channel` ) );
|
|
|
|
await serveM3UPlaylist( request, response );
|
|
return;
|
|
}
|
|
|
|
if ( loadAsset.startsWith( '/key' ) && method === 'GET' )
|
|
{
|
|
Log.info( `Received request for key data`, chalk.white( ` → ` ), chalk.grey( `/key` ) );
|
|
|
|
await serveKey( request, response );
|
|
return;
|
|
}
|
|
|
|
if ( loadAsset === '/epg' && method === 'GET' )
|
|
{
|
|
Log.info( `Received request for EPG data`, chalk.white( ` → ` ), chalk.grey( `/epg` ) );
|
|
|
|
await serveXML( response, request );
|
|
return;
|
|
}
|
|
|
|
if ( loadAsset === '/tar' && method === 'GET' )
|
|
{
|
|
Log.info( `Received request for EPG data`, chalk.white( ` → ` ), chalk.grey( `/epg` ) );
|
|
|
|
await serveTAR( response, request );
|
|
return;
|
|
}
|
|
|
|
/*
|
|
General Template & .html / .css / .js
|
|
read the loaded asset file
|
|
*/
|
|
|
|
ejs.renderFile( './www/' + loadAsset, { fileXML: envFileXML, fileM3U: envFileM3U, fileTAR: envFileTAR, appName: name, appVersion: version }, ( err, data ) =>
|
|
{
|
|
if ( !err )
|
|
{
|
|
const html = data.toString();
|
|
|
|
/*
|
|
This allows us to serve all files locally: css, js, etc.
|
|
the file loaded is dependent on what comes to the right of the period.
|
|
*/
|
|
|
|
const fileExt = loadAsset.lastIndexOf( '.' );
|
|
const fileMime = fileExt === -1
|
|
? 'text/plain'
|
|
: {
|
|
'.html' : 'text/html',
|
|
'.ico' : 'image/x-icon',
|
|
'.jpg' : 'image/jpeg',
|
|
'.png' : 'image/png',
|
|
'.gif' : 'image/gif',
|
|
'.css' : 'text/css',
|
|
'.gz' : 'application/gzip',
|
|
'.js' : 'text/javascript'
|
|
}[loadAsset.substr( fileExt )];
|
|
|
|
response.setHeader( 'Content-type', fileMime );
|
|
response.end( html );
|
|
|
|
Log.debug( `www`, chalk.green( ` [LOAD] ` ), chalk.white( ` → ` ), chalk.grey( `asset:${ loadAsset } mime:${ fileMime }` ) );
|
|
}
|
|
else
|
|
{
|
|
Log.error( `www file not found:`, chalk.white( ` → ` ), chalk.grey( `${ request.url }` ) );
|
|
response.writeHead( 404, 'Not Found' );
|
|
response.end();
|
|
}
|
|
});
|
|
};
|
|
handleRequest().catch( ( error ) =>
|
|
{
|
|
Log.error( `Error handling request:`, chalk.white( ` → ` ), chalk.grey( `${ error }` ) );
|
|
|
|
response.writeHead( 500, {
|
|
'Content-Type': 'text/plain'
|
|
});
|
|
|
|
response.end( 'Internal Server Error' );
|
|
});
|
|
});
|
|
|
|
/*
|
|
Initialize Webserver
|
|
*/
|
|
|
|
( async() =>
|
|
{
|
|
const envWebIP = process.env.WEB_IP || '0.0.0.0';
|
|
const envWebPort = process.env.WEB_PORT || `4124`;
|
|
|
|
await initialize();
|
|
server.listen( envWebPort, envWebIP, () =>
|
|
{
|
|
Log.info( `Server is running on ${ envWebIP }:${ envWebPort }` );
|
|
});
|
|
})();
|