From 6965d2eaa49832b995027546cd4ce21320cb5b3f Mon Sep 17 00:00:00 2001 From: Aetherinox Date: Sun, 23 Mar 2025 03:22:06 -0700 Subject: [PATCH] chore: template html migrated to independent file --- tvapp2/index.js | 1339 ++++++++++++++++++----------------------------- 1 file changed, 501 insertions(+), 838 deletions(-) diff --git a/tvapp2/index.js b/tvapp2/index.js index 110bdd2f..8afb73fd 100755 --- a/tvapp2/index.js +++ b/tvapp2/index.js @@ -4,38 +4,36 @@ Import Packages */ -import os from 'os' -import fs from 'fs' -import https from 'https' +import fs from 'fs'; +import https from 'https'; import path from 'path'; -import http from 'http' -import zlib from 'zlib' +import http from 'http'; +import zlib from 'zlib'; import chalk from 'chalk'; -import UserAgent from 'user-agents'; -const cache = new Map(); - -/* - Import package.json values -*/ - -const { name, author, version, repository } = JSON.parse(fs.readFileSync('./package.json')); /* Old CJS variables converted to ESM */ import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); // get resolved path to file -const __dirname = path.dirname(__filename); // get name of directory +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) + - 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 @@ -60,15 +58,15 @@ const envUrlRepo = process.env.URL_REPO || `https://git.binaryninja.net/binaryni const envStreamQuality = process.env.STREAM_QUALITY || `hd`; const envFilePlaylist = process.env.FILE_PLAYLIST || `playlist.m3u8`; const envFileEPG = process.env.FILE_EPG || `xmltv.xml`; -const LOG_LEVEL = process.env.LOG_LEVEL || 4; +const LOG_LEVEL = process.env.LOG_LEVEL || 10; /* Define > Externals */ -const extURL = `${envUrlRepo}/tvapp2-externals/raw/branch/main/urls.txt`; -const extEPG = `${envUrlRepo}/XMLTV-EPG/raw/branch/main/xmltv.1.xml`; -const extFormatted = `${envUrlRepo}/tvapp2-externals/raw/branch/main/formatted.dat`; +const extURL = `${ envUrlRepo }/tvapp2-externals/raw/branch/main/urls.txt`; +const extEPG = `${ envUrlRepo }/XMLTV-EPG/raw/branch/main/xmltv.1.xml`; +const extFormatted = `${ envUrlRepo }/tvapp2-externals/raw/branch/main/formatted.dat`; const extEvents = ''; /* @@ -76,17 +74,7 @@ const extEvents = ''; */ let urls = []; -let tokenData = { - subdomain: null, - token: null, - url: null, - validationUrl: null, - cookies: null, -}; - -let lastTokenFetchTime = 0; - -let gCookies = {}; +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'; /* @@ -119,45 +107,54 @@ const USERAGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 1 Error */ -class Log { - static now() { +class Log +{ + static now() + { const now = new Date(); - return chalk.gray(`[${now.toLocaleTimeString()}]`) + return chalk.gray( `[${ now.toLocaleTimeString() }]` ); } - static trace(...message) { - if (LOG_LEVEL >= 6) - console.trace(chalk.white.bgMagenta.bold(` ${name} `), chalk.white(` → `), this.now(), chalk.magentaBright(message.join(" "))) + static trace( ...msg ) + { + if ( LOG_LEVEL >= 6 ) + console.trace( chalk.white.bgMagenta.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.magentaBright( msg.join( ' ' ) ) ); } - static debug(...message) { - if (LOG_LEVEL >= 5) - console.debug(chalk.white.bgGray.bold(` ${name} `), chalk.white(` → `), this.now(), chalk.gray(message.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(...message) { - if (LOG_LEVEL >= 4) - console.info(chalk.white.bgBlueBright.bold(` ${name} `), chalk.white(` → `), this.now(), chalk.blueBright(message.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(...message) { - if (LOG_LEVEL >= 4) - console.log(chalk.white.bgGreen.bold(` ${name} `), chalk.white(` → `), this.now(), chalk.greenBright(message.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(...message) { - if (LOG_LEVEL >= 3) - console.log(chalk.white.bgYellow.bold(` ${name} `), chalk.white(` → `), this.now(), chalk.yellowBright(message.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(...message) { - if (LOG_LEVEL >= 2) - console.warn(chalk.white.bgYellow.bold(` ${name} `), chalk.white(` → `), this.now(), chalk.yellow(message.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(...message) { - if (LOG_LEVEL >= 1) - console.error(chalk.white.bgRedBright.bold(` ${name} `), chalk.white(` → `), this.now(), chalk.red(message.join(" "))) + static error( ...msg ) + { + if ( LOG_LEVEL >= 1 ) + console.error( chalk.white.bgRedBright.bold( ` ${ name } ` ), chalk.white( ` → ` ), this.now(), chalk.red( msg.join( ' ' ) ) ); } } @@ -165,18 +162,21 @@ class Log { Process */ -if (process.pkg) { - Log.info(`Processing Package`); - const basePath = path.dirname(process.execPath); - URLS_FILE = path.join(basePath, 'urls.txt'); - FORMATTED_FILE = path.join(basePath, 'formatted.dat'); - EPG_FILE = path.join(basePath, 'xmltv.1.xml'); +if ( process.pkg ) +{ + Log.info( `Processing Package` ); + const basePath = path.dirname( process.execPath ); + URLS_FILE = path.join( basePath, 'urls.txt' ); + FORMATTED_FILE = path.join( basePath, 'formatted.dat' ); + EPG_FILE = path.join( basePath, 'xmltv.1.xml' ); EPG_FILE.length; -} else { - Log.info(`Processing Locals`); - URLS_FILE = path.resolve(__dirname, 'urls.txt'); - FORMATTED_FILE = path.resolve(__dirname, 'formatted.dat'); - EPG_FILE = path.resolve(__dirname, 'xmltv.1.xml'); +} +else +{ + Log.info( `Processing Locals` ); + URLS_FILE = path.resolve( __dirname, 'urls.txt' ); + FORMATTED_FILE = path.resolve( __dirname, 'formatted.dat' ); + EPG_FILE = path.resolve( __dirname, 'xmltv.1.xml' ); } /* @@ -185,22 +185,28 @@ if (process.pkg) { allows multiple threads to work with the same shared resources */ -class Semaphore { - constructor(max) { +class Semaphore +{ + constructor( max ) + { this.max = max; this.queue = []; this.active = 0; } - async acquire() { - if (this.active < this.max) { + async acquire() + { + if ( this.active < this.max ) + { this.active++; return; } - return new Promise((resolve) => this.queue.push(resolve)); + return new Promise( ( resolve ) => this.queue.push( resolve ) ); } - release() { + release() + { this.active--; - if (this.queue.length > 0) { + if ( this.queue.length > 0 ) + { const resolve = this.queue.shift(); this.active++; resolve(); @@ -214,7 +220,7 @@ class Semaphore { @arg int threads_max */ -const semaphore = new Semaphore(5); +const semaphore = new Semaphore( 5 ); /* Func > Download File @@ -224,28 +230,34 @@ const semaphore = new Semaphore(5); @return Promise<> */ -async function downloadFile(url, filePath) { - Log.info(`Fetching ${url}`) +async function downloadFile( url, filePath ) +{ + Log.info( `Fetching ${ url }` ); - return new Promise((resolve, reject) => { - const isHttps = new URL(url).protocol === 'https:'; + return new Promise( ( resolve, reject ) => + { + const isHttps = new URL( url ).protocol === 'https:'; const httpModule = isHttps ? https : http; - const file = fs.createWriteStream(filePath); + 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}`)); + .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)); + 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)); + .on( 'error', ( err ) => + { + Log.error( `Error downloading file: ${ url }`, chalk.white( ` → ` ), chalk.grey( `Status code: ${ err.message }` ) ); + fs.unlink( filePath, () => reject( err ) ); }); }); } @@ -263,297 +275,332 @@ async function downloadFile(url, filePath) { @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}`)); - +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; } } } -// REMOVED REFERENCE CALLS TO THIS FUNCTION -// TODO: UPDATES TO HANDLER FOR SPORT EVENTS -async function fetchSportsData() { - return new Promise((resolve, reject) => { - const isHttps = new URL(extEvents).protocol === 'https:'; - const httpModule = isHttps ? require('https') : require('http'); - httpModule - .get(url, (response) => { - if (response.statusCode !== 200) { - Log.error(`Failed to fetch sports data. Server returned status code other than 200`, chalk.white(` → `), chalk.grey(`${url} - ${response.statusCode}`)); - return reject(new Error(`Failed to fetch sports data. Status code: ${response.statusCode}`)); - } - - let data = ''; - response.on('data', (chunk) => (data += chunk)); - response.on('end', () => { - Log.ok(`Fetched sports data successfully`); - resolve(data); - }); - }) - .on('error', (err) => { - Log.error(`Error fetching sports data:`, chalk.white(` → `), chalk.grey(`${err.message}`)); - reject(err); - }); - }); -} - -async function fetchRemote(url) { - return new Promise((resolve, reject) => { - const mod = url.startsWith('https') ? https : http; +async function fetchRemote( url ) +{ + return new Promise( ( resolve, reject ) => + { + const mod = url.startsWith( 'https' ) ? https : http; mod - .get(url, { + .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}`)); + }, ( 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); + 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); + + 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 === '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 if ( encoding === 'br' ) + { + zlib.brotliDecompress( buffer, ( err, decoded ) => + { + if ( err ) return reject( err ); + resolve( decoded ); }); - } else { - resolve(buffer); + } + else + { + resolve( buffer ); } }); }) - .on('error', reject); + .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, { +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}`)); + Log.error( `Missing "uri" parameter for key download`, chalk.white( ` → ` ), chalk.grey( `${ req.url }` ) ); - return res.end('Error: Missing "uri" parameter for key download.'); + return res.end( 'Error: Missing "uri" parameter for key download.' ); } - const keyData = await fetchRemote(uriParam); - res.writeHead(200, { + const keyData = await fetchRemote( uriParam ); + res.writeHead( 200, { 'Content-Type': 'application/octet-stream' }); - res.end(keyData); + res.end( keyData ); + } + catch ( err ) + { + Log.error( `ServeKey Error:`, chalk.white( ` → ` ), chalk.grey( `${ err.message }` ) ); - } catch (err) { - Log.error(`ServeKey Error:`, chalk.white(` → `), chalk.grey(`${err.message}`)); - - res.writeHead(500, { + res.writeHead( 500, { 'Content-Type': 'text/plain' }); - res.end('Error fetching key.'); + 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) { +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() { +function buildCookieHeader() +{ const pairs = []; - for (const [k, v] of Object.entries(gCookies)) { - pairs.push(`${k}=${v}`); + for ( const [ k, v ] of Object.entries( gCookies ) ) + { + pairs.push( `${ k }=${ v }` ); } - return pairs.join('; '); + return pairs.join( '; ' ); } -function fetchPage(url) { - return new Promise((resolve, reject) => { +function fetchPage( url ) +{ + return new Promise( ( resolve, reject ) => + { const opts = { method: 'GET', headers: { 'User-Agent': USERAGENT, Accept: '*/*', - Cookie: buildCookieHeader(), - }, + Cookie: buildCookieHeader() + } }; https - .get(url, opts, (res) => { - if (res.statusCode !== 200) { - return reject(new Error(`Non-200 status ${res.statusCode} => ${url}`)); + .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']); + if ( res.headers['set-cookie']) + { + parseSetCookieHeaders( res.headers['set-cookie']); } let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => resolve(data)); + res.on( 'data', ( chunk ) => ( data += chunk ) ); + res.on( 'end', () => resolve( data ) ); }) - .on('error', reject); + .on( 'error', reject ); }); } -async function getTokenizedUrl(channelUrl) { - try { - const html = await fetchPage(channelUrl); +async function getTokenizedUrl( channelUrl ) +{ + try + { + const html = await fetchPage( channelUrl ); let streamName; let streamHost; - if (channelUrl.includes('espn-')) { + if ( channelUrl.includes( 'espn-' ) ) + { streamName = 'ESPN'; - } else if (channelUrl.includes('espn2-')) { + } + 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}`)); + } + 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')) { + if ( channelUrl.match( 'tvpass\.org' ) ) + { streamHost = 'tvpass.org'; }; - if (channelUrl.match('thetvapp\.to')) { + if ( channelUrl.match( 'thetvapp\.to' ) ) + { streamHost = 'thetvapp.to'; }; - const tokenUrl = `https://${streamHost}/token/${streamName}?quality=${envStreamQuality}`; - const tokenResponse = await fetchPage(tokenUrl); + const tokenUrl = `https://${ streamHost }/token/${ streamName }?quality=${ envStreamQuality }`; + const tokenResponse = await fetchPage( tokenUrl ); let finalUrl; - try { - const json = JSON.parse(tokenResponse); + 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}`)); + } + 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}`)); + 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}`)); + 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}`)); + } + catch ( err ) + { + Log.error( `Fatal error fetching token:`, chalk.white( ` → ` ), chalk.grey( `${ err.message }` ) ); return null; } } -async function serveChannelPlaylist(req, res) { +async function serveChannelPlaylist( 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, { + 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.'); + res.end( 'Error: Missing URL parameter.' ); return; } - const decodedUrl = decodeURIComponent(urlParam); - if (decodedUrl.endsWith('.ts')) { - res.writeHead(302, { + 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 rewritePlaylist(cachedUrl, req); - res.writeHead(200, + const cachedUrl = getCache( decodedUrl ); + if ( cachedUrl ) + { + const rewrittenPlaylist = await rewritePlaylist( cachedUrl, req ); + res.writeHead( 200, { 'Content-Type': 'application/vnd.apple.mpegurl', - 'Content-Disposition': 'inline; filename="' + envFilePlaylist, + 'Content-Disposition': 'inline; filename="' + envFilePlaylist }); - res.end(rewrittenPlaylist); + + res.end( rewrittenPlaylist ); return; } - Log.info(`Fetching stream:`, chalk.white(` → `), chalk.grey(`${urlParam}`)); + Log.info( `Fetching stream:`, chalk.white( ` → ` ), chalk.grey( `${ urlParam }` ) ); - const finalUrl = await getTokenizedUrl(decodedUrl); - if (!finalUrl) { - Log.error(`Failed to retrieve tokenized URL`); + const finalUrl = await getTokenizedUrl( decodedUrl ); + if ( !finalUrl ) + { + Log.error( `Failed to retrieve tokenized URL` ); - res.writeHead(500, { + res.writeHead( 500, { 'Content-Type': 'text/plain' }); - res.end('Error: Failed to retrieve tokenized URL.'); + 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 rewritePlaylist(hdUrl, req); - res.writeHead(200, { + setCache( decodedUrl, finalUrl, 4 * 60 * 60 * 1000 ); + const hdUrl = finalUrl.replace( 'tracks-v2a1', 'tracks-v1a1' ); + const rewrittenPlaylist = await rewritePlaylist( hdUrl, req ); + res.writeHead( 200, { 'Content-Type': 'application/vnd.apple.mpegurl', - 'Content-Disposition': 'inline; filename="' + envFilePlaylist, + 'Content-Disposition': 'inline; filename="' + envFilePlaylist }); - res.end(rewrittenPlaylist); - Log.ok(`Served playlist`); - } catch (error) { - Log.error(`Error processing request:`, chalk.white(` → `), chalk.grey(`${error.message}`)); + 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, { + if ( !res.headersSent ) + { + res.writeHead( 500, { 'Content-Type': 'text/plain' }); - res.end('Error processing request.'); + res.end( 'Error processing request.' ); } - - } finally { + } + finally + { semaphore.release(); } } @@ -562,24 +609,28 @@ async function serveChannelPlaylist(req, res) { Rewrites the URLs */ -async function rewritePlaylist(originalUrl, req) { - const rawData = await fetchRemote(originalUrl); - const protocol = req.headers['x-forwarded-proto']?.split(',')[0] || (req.socket.encrypted ? 'https' : 'http'); +async function rewritePlaylist( 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'); + 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( /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( /^([^#].*\.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)}`; + .replace( /^([^#].*\.ts)(\?.*)?$/gm, ( match, uri ) => + { + const resolvedUri = new URL( uri, originalUrl ).href; + return `${ baseUrl }/channel?url=${ encodeURIComponent( resolvedUri ) }`; }); } @@ -587,658 +638,268 @@ async function rewritePlaylist(originalUrl, req) { Serves IPTV .m3u playlist */ -async function servePlaylist(response, req) { - - try { - - const protocol = req.headers['x-forwarded-proto']?.split(',')[0] || (req.socket.encrypted ? 'https' : 'http'); +async function servePlaylist( 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(FORMATTED_FILE, 'utf-8'); + const baseUrl = `${ protocol }://${ host }`; + const formattedContent = fs.readFileSync( FORMATTED_FILE, 'utf-8' ); const updatedContent = formattedContent - .replace(/(https?:\/\/[^\s]*thetvapp[^\s]*)/g, (fullUrl) => { - return `${baseUrl}/channel?url=${encodeURIComponent(fullUrl)}`; + .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)}`; + .replace( /(https?:\/\/[^\s]*tvpass[^\s]*)/g, ( fullUrl ) => + { + return `${ baseUrl }/channel?url=${ encodeURIComponent( fullUrl ) }`; }); - response.writeHead(200, { + response.writeHead( 200, { 'Content-Type': 'application/x-mpegURL', - 'Content-Disposition': 'inline; filename="' + envFilePlaylist, + 'Content-Disposition': 'inline; filename="' + envFilePlaylist }); - response.end(updatedContent); + response.end( updatedContent ); + } + catch ( error ) + { + Log.error( `Error in servePlaylist:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) ); - } catch (error) { - Log.error(`Error in servePlaylist:`, chalk.white(` → `), chalk.grey(`${error.message}`)); - - response.writeHead(500, { + response.writeHead( 500, { 'Content-Type': 'text/plain' }); - response.end(`Error serving playlist: ${error.message}`); + response.end( `Error serving playlist: ${ error.message }` ); } - } /* Serves IPTV .xml guide data */ -async function serveXmltv(response, req) { - - try { - - const protocol = req.headers['x-forwarded-proto']?.split(',')[0] || (req.socket.encrypted ? 'https' : 'http'); +async function serveXmltv( 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(EPG_FILE, 'utf-8'); + const baseUrl = `${ protocol }://${ host }`; + const formattedContent = fs.readFileSync( EPG_FILE, 'utf-8' ); - response.writeHead(200, { + response.writeHead( 200, { 'Content-Type': 'application/xml', - 'Content-Disposition': 'inline; filename="xmltv.1.xml"', + 'Content-Disposition': 'inline; filename="xmltv.1.xml"' }); - response.end(formattedContent); + response.end( formattedContent ); + } + catch ( error ) + { + Log.error( `Error in servePlaylist:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) ); - } catch (error) { - - Log.error(`Error in servePlaylist:`, chalk.white(` → `), chalk.grey(`${error.message}`)); - - response.writeHead(500, { + response.writeHead( 500, { 'Content-Type': 'text/plain' }); - response.end(`Error serving playlist: ${error.message}`); + response.end( `Error serving playlist: ${ error.message }` ); } - }; -function setCache(key, value, ttl) { +function setCache( key, value, ttl ) +{ const expiry = Date.now() + ttl; - cache.set(key, { + cache.set( key, { value, expiry }); - Log.debug(`Cache set for key ${key} which expires in`, chalk.white(` → `), chalk.grey(`${ttl / 1000} seconds`)); + 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()) { +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}`)); + } + else + { + if ( cached ) + Log.debug( `Cache expired for key`, chalk.white( ` → ` ), chalk.grey( `${ key }` ) ); - cache.delete(key); + cache.delete( key ); return null; } } -async function initialize() { - try { - Log.info(`Initializing server...`); +async function initialize() +{ + try + { + Log.info( `Initializing server...` ); - await ensureFileExists(extURL, URLS_FILE); - await ensureFileExists(extFormatted, FORMATTED_FILE); - await ensureFileExists(extEPG, EPG_FILE); + await ensureFileExists( extURL, URLS_FILE ); + await ensureFileExists( extFormatted, FORMATTED_FILE ); + await ensureFileExists( extEPG, EPG_FILE ); - urls = fs.readFileSync(URLS_FILE, 'utf-8').split('\n').filter(Boolean); - if (urls.length === 0) { - throw new Error(`No valid URLs found in ${URLS_FILE}`); + urls = fs.readFileSync( URLS_FILE, 'utf-8' ).split( '\n' ).filter( Boolean ); + if ( urls.length === 0 ) + { + throw new Error( `No valid URLs found in ${ URLS_FILE }` ); } - Log.info(`Initializing Complete`); - } catch (error) { - Log.error(`Initialization error:`, chalk.white(` → `), chalk.grey(`${error.message}`)); + Log.info( `Initializing Complete` ); + } + catch ( error ) + { + Log.error( `Initialization error:`, chalk.white( ` → ` ), chalk.grey( `${ error.message }` ) ); } } -const server = http.createServer((req, res) => { - const handleRequest = async () => { - const protocol = req.headers['x-forwarded-proto']?.split(',')[0] || (req.socket.encrypted ? 'https' : 'http'); - const host = req.headers.host; - const baseUrl = `${protocol}://${host}`; - if (req.url === '/' && req.method === 'GET') { - const htmlContent = ` - - - - - TVApp2 - File Browser - - - - - - - - - -
- -
- -
-
-
-
This page displays your most recent copies of the .m3u8 playlist and .xml EPG guide data. Right-click each file, select Copy Link and paste the URLs within an IPTV app such as Jellyfin. The .m3u8 and .m3u8.gz are identical guide lists, but the .xml.gz is compressed and will import into your IPTV application much faster.
-
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
- File Name - - Link - - Description -
- - - - - Playlist data file which contains a list of all channels, their associated group, and logo URL.
- - - - - XML / EPG guide data which contains a list of all programs which are scheduled to play on a specific channel.
-
- -
-
-
-
- - - - - - - - -`; - res.writeHead(200, { - 'Content-Type': 'text/html' - }); - res.end(htmlContent); + await servePlaylist( response, request ); return; } - if (req.url === '/playlist' && req.method === 'GET') { - Log.info(`Received request for playlist data`, chalk.white(` → `), chalk.grey(`/playlist`)); + if ( loadAsset.startsWith( '/channel' ) && method === 'GET' ) + { + Log.info( `Received request for channel data`, chalk.white( ` → ` ), chalk.grey( `/channel` ) ); - await servePlaylist(res, req); + await serveChannelPlaylist( request, response ); return; } - if (req.url.startsWith('/channel') && req.method === 'GET') { - Log.info(`Received request for channel data`, chalk.white(` → `), chalk.grey(`/channel`)); + if ( loadAsset.startsWith( '/key' ) && method === 'GET' ) + { + Log.info( `Received request for key data`, chalk.white( ` → ` ), chalk.grey( `/key` ) ); - await serveChannelPlaylist(req, res); + await serveKey( request, response ); return; } - if (req.url.startsWith('/key') && req.method === 'GET') { - Log.info(`Received request for key data`, chalk.white(` → `), chalk.grey(`/key`)); + if ( loadAsset === '/epg' && method === 'GET' ) + { + Log.info( `Received request for EPG data`, chalk.white( ` → ` ), chalk.grey( `/epg` ) ); - await serveKey(req, res); + await serveXmltv( response, request ); return; } - if (req.url === '/epg' && req.method === 'GET') { - Log.info(`Received request for EPG data`, chalk.white(` → `), chalk.grey(`/epg`)); + /* + General Template & .html / .css / .js + read the loaded asset file + */ - await serveXmltv(res, req); - return; - } + fs.readFile( './' + loadAsset, ( err, data ) => + { + if ( !err ) + { + /* + @todo currently, the index.html has certain template variables loaded using str.replace; + this can be more easily managed by using ejs - res.writeHead(404, { - 'Content-Type': 'text/plain' + import ejs from 'ejs'; + app.post("index.html", (req, res) => { + const token = req.body.data.token; + ejs.renderFile("./index.ejs", {token: token}, (error, output) => { + // other functionality + }) + } + */ + + const html = data.toString() + .replace( '${ file_m3u }', envFilePlaylist ) + .replace( '${ file_epg }', envFileEPG ) + .replace( '${ version }', version ); + + /* + 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', + '.js' : 'text/javascript' + }[loadAsset.substr( fileExt )]; + + response.setHeader( 'Content-type', fileMime ); + response.end( html ); + + Log.debug( `Web server [LOAD]`, chalk.white( ` → ` ), chalk.grey( `${ loadAsset } / ${ fileMime }` ) ); + } + else + { + Log.error( `Webserver file not found:`, chalk.white( ` → ` ), chalk.grey( `${ request.url }` ) ); + + response.writeHead( 404, 'Not Found' ); + response.end(); + } }); - - res.end('Not Found'); }; - handleRequest().catch((error) => { - Log.error(`Error handling request:`, chalk.white(` → `), chalk.grey(`${error}`)); + handleRequest().catch( ( error ) => + { + Log.error( `Error handling request:`, chalk.white( ` → ` ), chalk.grey( `${ error }` ) ); - res.writeHead(500, { + response.writeHead( 500, { 'Content-Type': 'text/plain' }); - res.end('Internal Server Error'); + response.end( 'Internal Server Error' ); }); }); @@ -1246,12 +907,14 @@ const server = http.createServer((req, res) => { Initialize Webserver */ -(async () => { +( 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}`) + server.listen( envWebPort, envWebIP, () => + { + Log.info( `Server is running on ${ envWebIP }:${ envWebPort }` ); }); })();