diff --git a/tvapp2/classes/CLib.js b/tvapp2/classes/CLib.js new file mode 100644 index 00000000..280e50be --- /dev/null +++ b/tvapp2/classes/CLib.js @@ -0,0 +1,168 @@ +/* + Compress / Uncompress String with base64 + + these functions use a unique character table. moving the letters around will cause strings to not + be in the correct order once uncompressed. + + @usage new CLib().compress( 'https://daddylive.mp/' ) + new CLib().uncompress( 'burS7u6FvUHhZfrhkfJoYz8CswTD=' ) + new CLib().translate( '=', plugin.defTrans, plugin.tvaTrans ) + + a custom character set can be specified with two additional parameters. however, anything prior + that was encoded will not be decoded by the new character set. + + const strCompress = new CLib().compress( 'test.com' ); + const strUncompress = new CLib().uncompress( strCompress ); + + new CLib().compress( 'test.com', 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 'rXzxP9ZdvehYlstwiTuV1c07j45Abo2Ama6k3gqpyf8n+/NMSEIUHBQRJDLFCGKO' ) + new CLib().uncompress( 'oZcUozDkAQH=', 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 'rXzxP9ZdvehYlstwiTuV1c07j45Abo2Ama6k3gqpyf8n+/NMSEIUHBQRJDLFCGKO' ) +*/ + +import chalk from 'chalk'; +import Log from './Log.js'; + +/* + Class > CLib +*/ + +class CLib +{ + constructor() + { + this.defTrans = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + this.tvaTrans = 'TVAPp29uqXiv6g5adr1j8nfwZ0bs7Ykm3xl4hczAtoey+/CDKJULSEMBQRFGIHNO'; + } + + compress( data, defTrans, tvaTrans ) + { + if ( typeof data === 'string' ) + data = Buffer.from( data, 'utf8' ); + + const transDef = defTrans || this.defTrans; + const transTva = tvaTrans || this.tvaTrans; + + try + { + const dataCompress = this.translate( data.toString( 'base64' ), transDef, transTva ); + + Log.ok( `clib`, chalk.yellow( `[compress]` ), chalk.white( `⚙️` ), + chalk.blueBright( `` ), chalk.gray( `Compress string` ), + chalk.blueBright( `` ), chalk.gray( `${ data }` ), + chalk.blueBright( `` ), chalk.gray( `${ dataCompress }` ) ); + + return dataCompress; + } + catch ( err ) + { + Log.error( `clib`, chalk.redBright( `[compress]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Could not compress string; bad string ${ data }` ), + chalk.redBright( `` ), chalk.gray( `${ err.message }` ), + chalk.redBright( `` ), chalk.gray( `${ data }` ) ); + + return null; + } + } + + uncompress( data, defTrans, tvaTrans ) + { + if ( Buffer.isBuffer( data ) ) + data = data.toString(); + + const transDef = defTrans || this.defTrans; + const transTva = tvaTrans || this.tvaTrans; + + try + { + const dataTranslated = this.translate( data, transTva, transDef ); + const dataUncompress = Buffer.from( dataTranslated, 'base64' ).toString( 'utf8' ); + + Log.ok( `clib`, chalk.yellow( `[decompss]` ), chalk.white( `⚙️` ), + chalk.blueBright( `` ), chalk.gray( `Uncompress string` ), + chalk.blueBright( `` ), chalk.gray( `${ data }` ), + chalk.blueBright( `` ), chalk.gray( `${ dataUncompress }` ) ); + + return dataUncompress; + } + catch ( err ) + { + Log.error( `clib`, chalk.redBright( `[decompss]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Could not uncompress string; bad string ${ data }` ), + chalk.redBright( `` ), chalk.gray( `${ err.message }` ), + chalk.redBright( `` ), chalk.gray( `${ data }` ) ); + + return null; + } + } + + /* + Translate + + compresses or decompresses encoded strings for the functions: + - compress + - uncompress + */ + + translate( str, fromChars, toChars ) + { + let res = ''; + for ( let i = 0;i < str.length;i++ ) + { + const char = str[i]; + const index = fromChars.indexOf( char ); + if ( index !== -1 ) + res += toChars[index]; + else + res += char; + } + + return res; + } + + /* + Encode: String > Hex > Base64 + + encodes a human-readable string into a hex value, and then to base64 + + @usage const clib = new CLib() + const encoded = clib.encodeToHexBase64('hello'); // Njg2NTZjNmM2Zg== + const decoded = clib.decodeFromHexBase64(`${ encoded }`); // hello + */ + + encodeToHexBase64( str ) + { + const hex = [...str].map( char => + { + const code = char.charCodeAt(0).toString(16); + return code.padStart(2, '0'); + }).join(''); + + const base64 = btoa(hex); + return base64; + } + + /* + Decode: Base64 > Hex > String + + decodes a base64 value to hex, and then back into a human readable string + + @usage const clib = new CLib() + const encoded = clib.encodeToHexBase64('hello'); // Njg2NTZjNmM2Zg== + const decoded = clib.decodeFromHexBase64(`${ encoded }`); // hello + */ + + decodeFromHexBase64( base64Str ) + { + const hex = atob(base64Str); + const chars = hex.match(/.{1,2}/g); // every 2 hex chars = 1 byte + + return chars.map(byte => String.fromCharCode(parseInt(byte, 16))).join(''); + } +} + +/* + export class + + @usage import CLib from './classes/CLib.js'; +*/ + +export default CLib; diff --git a/tvapp2/classes/Log.js b/tvapp2/classes/Log.js new file mode 100644 index 00000000..d4dc5589 --- /dev/null +++ b/tvapp2/classes/Log.js @@ -0,0 +1,120 @@ +/* + 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.verbose(`This is verbose`) + 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 +*/ + +import fs from 'fs'; +import chalk from 'chalk'; + +/* + 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 +*/ + +const LOG_LEVEL = process.env.LOG_LEVEL || 4; +const { name } = JSON.parse( fs.readFileSync( './package.json' ) ); + +/* + Class > Log +*/ + +class Log +{ + static now() + { + const now = new Date(); + return chalk.gray( `[${ now.toLocaleTimeString() }]` ); + } + + static verbose( ...msg ) + { + if ( LOG_LEVEL >= 6 ) + console.debug( chalk.white.bgBlack.blackBright.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.gray( msg.join( ' ' ) ) ); + } + + static debug( ...msg ) + { + if ( LOG_LEVEL >= 7 ) + console.trace( chalk.white.bgMagenta.bold( ` ${ name } ` ), chalk.white( `⚙️` ), this.now(), chalk.magentaBright( msg.join( ' ' ) ) ); + else 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.yellowBright( msg.join( ' ' ) ) ); + } + + static error( ...msg ) + { + if ( LOG_LEVEL >= 1 ) + console.error( chalk.white.bgRedBright.bold( ` ${ name } ` ), chalk.white( `❌` ), this.now(), chalk.redBright( msg.join( ' ' ) ) ); + } +} + +/* + export class + + @usage import Log from './classes/Log.js'; +*/ + +export default Log; diff --git a/tvapp2/classes/Semaphore.js b/tvapp2/classes/Semaphore.js new file mode 100644 index 00000000..89714551 --- /dev/null +++ b/tvapp2/classes/Semaphore.js @@ -0,0 +1,46 @@ +/* + 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(); + } + } +} + +/* + export class + + @usage import Log from './classes/Log.js'; +*/ + +export default Semaphore; + diff --git a/tvapp2/classes/Storage.js b/tvapp2/classes/Storage.js new file mode 100644 index 00000000..8ac60439 --- /dev/null +++ b/tvapp2/classes/Storage.js @@ -0,0 +1,520 @@ +/* + Class › Storage + + The storage classes allows you to save specific settings into a json file. These settings are better off being stored in + a local file, instead of using up the resources being saved in a database. + + Class supports multiple storage files, but by default, it will save settings in `www/config.json`. + + Settings include Tuner / HDHomeRun device information, etc. + + @usage + const storage = new Storage( envWebFolder, FILE_CFG ); +*/ + +import chalk from 'chalk'; +import path from 'path'; +import nconf from 'nconf'; +import fs from 'fs'; +import Log from './Log.js'; +import Utils from './Utils.js'; +import { fileURLToPath } from 'url'; + +/* + CJS › ESM +*/ + +const __filename = fileURLToPath( import.meta.url ); // get resolved path to file +const __dirname = path.dirname( __filename ); // get name of directory + +/* + Class › Storage + + constructor ( str:folder, str:file ) + Initialize ( bool:bForceNew ) + Setup ( bool:bForceNew ) + Get ( str:key ) + Set ( str:key, any:value ) + Save ( ) + GetConfig ( ) + isJsonString ( json:str ) + isJsonEmpty ( obj:json ) +*/ + +class Storage +{ + /* + Constructor › Storage + + Initializes a Storage instance for managing the config.json file. + Determines the full path to the config file based on folder and file arguments, + or uses the default static fileConfig if none are provided. + + Handles Node.js packaged apps (process.pkg) by adjusting paths accordingly. + + @args + folder (str) Optional folder where config.json will be stored. Defaults to 'www'. + file (str) Optional config file name. Defaults to static Storage.fileConfig. + + @usage + const storage = new Storage(envWebFolder, FILE_CFG); + */ + + static fileConfig = path.resolve( process.cwd( ), 'www', 'config.json' ); + + constructor( folder, file ) + { + this.folderWeb = folder || 'www'; + this.fileConfig = file ? path.resolve( folder, file ) : Storage.fileConfig; + + if ( process.pkg ) + this.fileConfig = path.join( path.dirname( process.execPath ), this.folderWeb, this.fileConfig ); + else + this.fileConfig = path.resolve( process.cwd( ), this.folderWeb, this.fileConfig ); + } + + /* + Initialize › Activate Config Setup with Logging + + Activates the Storage.Setup( ) function while providing detailed logging. + Ensures the user's config.json file exists, is valid, and is initialized + with default values if missing or corrupt. + + Steps: + - Logs the start of initialization. + - Calls Setup( ) with optional force flag to recreate config. + - Catches and logs any errors during setup. + + @args + bForceNew (bool) Optional. If true, forces the config file to be removed + and regenerated from defaults. + + @returns + (Promise) Resolves when initialization completes, or logs an error if setup fails. + + @usage + const storage = new Storage(envWebFolder, FILE_CFG); + await storage.Initialize(false); + */ + + async Initialize( bForceNew ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + const bForce = bForceNew || false; + + try + { + Log.info( `conf`, chalk.yellow( `[initiate]` ), chalk.white( `ℹ️` ), + chalk.blueBright( `` ), chalk.gray( `Initializing config file` ), + chalk.blueBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + + await new Storage( ).Setup( bForce ); + } + catch ( err ) + { + console.log( 'Error writing Metadata.json:' + err.message ); + } + } + + /* + Initialize › Setup User Config File + + Sets up a user's config.json file, ensuring it exists and is valid JSON. + If the file is missing, empty, or invalid, it will be created or replaced. + Typically, you should call this via Storage( ).Initialize( ) rather than Setup( ) directly. + + Steps: + - Creates parent directory if it doesn't exist. + - Removes existing config if bForceNew is true. + - Validates existing JSON; backs up invalid files. + - Creates default config if missing. + - Wires up nconf with argv, env, file, and default values. + + @args + bForceNew (bool) Optional flag to force recreate the config file, wiping all existing data. + + @returns + (Promise) Resolves true when initialization completes successfully. + + @usage + const storage = new Storage(envWebFolder, FILE_CFG); + await storage.Initialize(false); + */ + + async Setup( bForceNew ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + return new Promise( ( resolve, reject ) => + { + try + { + Log.info( `conf`, chalk.yellow( `[generate]` ), chalk.white( `ℹ️` ), + chalk.blueBright( `` ), chalk.gray( `Initializing storage setup` ), + chalk.blueBright( `` ), chalk.gray( `${ bForceNew }` ), + chalk.blueBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + + /* + ensure parent directory exists + */ + const dirPath = path.dirname( this.fileConfig ); + + if ( !fs.existsSync( dirPath ) ) + { + fs.mkdirSync( dirPath, { recursive: true }); + } + + /* + if force flag is true, remove existing config file (force) + */ + + if ( bForceNew === true && fs.existsSync( this.fileConfig ) ) + { + Log.ok( `conf`, chalk.yellow( `[generate]` ), chalk.white( `✅` ), + chalk.greenBright( `` ), chalk.gray( `Remove original config; force new` ), + chalk.greenBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + + try + { + fs.unlinkSync( this.fileConfig ); + } + catch ( e ) + { + Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Failed to unlink existing config` ), + chalk.redBright( `` ), chalk.gray( `${ e.message }` ), + chalk.redBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + } + } + + /* + if config exists, validate JSON; if invalid, move to backup and recreate + */ + + if ( fs.existsSync( this.fileConfig ) ) + { + let raw = null; + let parsed = null; + + try + { + raw = fs.readFileSync( this.fileConfig, { encoding: 'utf8' }); + + if ( typeof raw !== 'string' || raw.trim( ).length === 0 ) + { + throw new Error( 'Empty config file' ); + } + + parsed = JSON.parse( raw ); + } + catch ( e ) + { + const backupPath = `${ this.fileConfig }.corrupt.${ Date.now( ) }`; + + try + { + fs.renameSync( this.fileConfig, backupPath ); + Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Config file invalid; moved to backup` ), + chalk.redBright( `` ), chalk.gray( `${ backupPath }` ), + chalk.redBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + } + catch ( renameErr ) + { + Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Unable to backup invalid config file` ), + chalk.redBright( `` ), chalk.gray( `${ renameErr.message }` ), + chalk.redBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + if ( this.rejected ) + { + reject( renameErr ); + return; + } + } + } + } + + /* + if config does not exist (or was just moved because it was corrupt), create it atomically + */ + + if ( !fs.existsSync( this.fileConfig ) ) + { + const defaults = + { + deviceId: 'FFFFFFFF' + }; + + const tempPath = `${ this.fileConfig }.tmp`; + + try + { + fs.writeFileSync( tempPath, JSON.stringify( defaults, null, 4 ), { encoding: 'utf8' }); + fs.renameSync( tempPath, this.fileConfig ); + + Log.ok( `conf`, chalk.yellow( `[generate]` ), chalk.white( `✅` ), + chalk.greenBright( `` ), chalk.gray( `Created new config file with defaults` ), + chalk.greenBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + } + catch ( writeErr ) + { + Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Failed to create config file` ), + chalk.redBright( `` ), chalk.gray( `${ writeErr.message }` ), + chalk.redBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + + if ( this.rejected ) + { + reject( writeErr ); + return; + } + } + } + + /* + now that file exists and is valid JSON, wire up nconf + */ + + nconf.argv( ).env({ parseValues: true }).file({ file: this.fileConfig }).defaults( + { + deviceId: 'FFFFFFFF' + }); + } + catch ( err ) + { + Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Could not generate and write to new config file` ), + chalk.redBright( `` ), chalk.gray( `${ err.message }` ), + chalk.redBright( `` ), chalk.gray( `${ this.fileConfig }` ) ); + + if ( this.rejected ) + { + reject( err ); + return; + } + } + + resolve( true ); + }); + } + + /* + Get › Retrieve Configuration Value + + Fetches a stored value from the application's persistent configuration + using the provided key via the nconf module. + + This function is static, so it can be called without creating a Storage instance. + + @args + key (str) The configuration key to retrieve. + + @returns + (any) The value associated with the key, or undefined if the key does not exist. + + @usage + const deviceId = Storage.Get('deviceId'); + */ + + static Get( key ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + return nconf.get( key ); + } + + /* + Set › Store Configuration Value + + Stores a value in the application's persistent configuration using + the provided key via the nconf module. Automatically saves the + updated configuration to disk by calling Storage.Save( ). + + This function is static, so it can be called without creating a Storage instance. + + @args + key (str) The configuration key to set. + value (any) The value to store under the specified key. + + @returns + (void) No return value. + + @usage + Storage.Set('deviceId', '105B35EF'); + */ + + static Set( key, value ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), + chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + nconf.set( key, value ); + Storage.Save( ); + } + + /* + Save › Persist Configuration to Disk + + Saves the current configuration stored in nconf to disk. + After saving, the method reads back the file to verify it is valid JSON + and logs detailed status messages about success or errors. + + @purpose + - Calls nconf.save() to write the current configuration. + - Reads back the saved file. + - Parses the file as JSON to confirm validity. + - Logs success or detailed error messages for failures. + + @args + none + + @returns + (void) Logs success or error; does not return a value. + + @usage + Storage.Save(); + */ + + static Save( ) + { + const filePath = this.fileConfig; + + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), + chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + nconf.save( ( err ) => + { + if ( err ) + { + Log.error( `conf`, chalk.redBright( `[snapshot]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Could not save config` ), + chalk.redBright( `` ), chalk.gray( `${ err }` ), + chalk.redBright( `` ), chalk.gray( `${ filePath }` ) ); + return; + } + + fs.readFile( filePath, ( err, data ) => + { + if ( err ) + { + Log.error( `conf`, chalk.redBright( `[snapshot]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Unable to read config file` ), + chalk.redBright( `` ), chalk.gray( `${ err }` ), + chalk.redBright( `` ), chalk.gray( `${ filePath }` ) ); + return; + } + + try + { + const parsed = JSON.parse( data.toString( ) ); + + Log.ok( `conf`, chalk.yellow( `[snapshot]` ), chalk.white( `✅` ), + chalk.greenBright( `` ), chalk.gray( `Save to config file successful` ), + chalk.greenBright( `` ), chalk.gray( `${ filePath }` ) ); + + Log.debug( `conf`, chalk.yellow( `[snapshot]` ), chalk.white( `⚙️` ), + chalk.blueBright( `` ), chalk.gray( `Read values from saved config file` ), + chalk.blueBright( `` ), chalk.gray( `${ filePath }` ), + chalk.blueBright( `` ), chalk.gray( `${ JSON.stringify( parsed ) }` ) ); + } + catch ( parseErr ) + { + Log.error( `conf`, chalk.redBright( `[snapshot]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Config file is not valid JSON` ), + chalk.redBright( `` ), chalk.gray( `${ parseErr.message }` ), + chalk.redBright( `` ), chalk.gray( `${ filePath }` ) ); + } + }); + }); + } + + /* + GetConfig › Return Full Path to Config File + + Returns the full path to the currently used config.json file for this Storage instance. + This is useful when you need to know the exact file location without reading its contents. + + @args + none + + @returns + (str) Absolute path to the config.json file. + + @usage + const storage_config = Storage.GetConfig(); + */ + + static GetConfig( ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + return this.fileConfig; + } + + /* + isJsonString › Check if Input is Valid JSON + + Determines whether a given string is valid JSON by attempting + to parse it. Returns true if parsing succeeds, false if it throws + an error. + + @args + json (str) The string to test for valid JSON. + + @returns + (bool) True if input is valid JSON, false otherwise. + + @usage + const valid = Storage.isJsonString('{"key":"value"}'); // returns true + */ + + static isJsonString( json ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + try + { + JSON.parse( json ); + } + catch ( e ) + { + return false; + } + + return true; + } + + /* + helper › json object empty + */ + + static isJsonEmpty( json ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + if ( Object.keys( json ).length === 0 ) + return true; + + if ( JSON.stringify( json ) === '\"{}\"' ) + return true; + + for ( const key in json ) + { + if ( ! Object.prototype.hasOwnProperty.call( json, key ) ) + return true; + } + + return false; + } +} + +/* + export class + + @import + import Storage from './classes/Storage.js'; +*/ + +// eslint-disable-next-line no-restricted-syntax +export default Storage; diff --git a/tvapp2/classes/Tuner.js b/tvapp2/classes/Tuner.js new file mode 100644 index 00000000..8388b621 --- /dev/null +++ b/tvapp2/classes/Tuner.js @@ -0,0 +1,453 @@ +/* + Class › Tuner + + Handles HDHomeRun device management and deviceId lifecycle. + + @purpose + - Generate / format HDHomeRun device IDs. + - Validate device IDs against HDHomeRun rules (length, hex chars, checksum). + - Persist device IDs using Storage class. + - Automatically generate new device ID if missing, invalid, or uninitialized (FFFFFFFF). + - Initialize tuner instances with validated device IDs. + + @usage + await new Tuner( Storage.Get( 'deviceId' ) ).Initialize( ); + const tuner = new Tuner( ); + await tuner.Initialize( ); + const validId = await tuner.VerifyDeviceId( ); + + @notes + - Device IDs are persisted via the Storage class (config.json). + - User's device id must be valid before HDHomeRun will initialize. +*/ + + +import chalk from 'chalk'; +import Storage from './Storage.js'; +import Utils from './Utils.js'; +import Log from './Log.js'; + +/* + Class › Tuner + + constructor ( str:deviceId ) + Initialize ( ) + Start ( ) + _GenerateDeviceId ( int:len ) + GenerateDeviceId ( ) + GetDeviceId ( ) + FormatDeviceId ( str:deviceid ) + IsDeviceIdValid ( ) + VerifyDeviceId ( ) +*/ + +class Tuner +{ + constructor( deviceId ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getConstructorName( ) }` ) ); + + this.Name = `HDHomeRun`; + this.FriendlyName = `TVApp2`; + this.ModelNumber = `HDHR5-4US`; + this.FirmwareName = `hdhomerun5_atsc`; + this.FirmwareVersion = `0.9.15.00-RC04`; + this.DeviceId = deviceId || Storage.Get( 'deviceId' ); + } + + /* + Initialize › Setup and Start Tuner + + Initializes the tuner by calling the Start( ) method. + Catches and logs any errors encountered during startup. + + @args + none + + @returns + (void) Logs status; does not return a value. + + @usage + await tuner.Initialize( ); + */ + + async Initialize( ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + try + { + await this.Start( ); + } + catch ( err ) + { + Log.error( `hdhr`, chalk.redBright( `[initiate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Failure initializing tuner` ), + chalk.redBright( `` ), chalk.gray( `${ err.message }` ) ); + } + } + + /* + Start › Initialize and Verify Device ID + + Starts the tuner by verifying the current deviceId. + If the deviceId is missing or invalid, it will be regenerated and validated. + Logs the status of the deviceId once verification completes. + + @args + none + + @returns + (bool) true if deviceId is valid after verification, false otherwise. + + @usage + await tuner.Start( ); + */ + + async Start( ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + const verifiedId = await new Tuner( ).VerifyDeviceId( this.DeviceId ); + + if ( await this.IsDeviceIdValid( verifiedId ) ) + { + Log.ok( `conf`, chalk.yellow( `[validate]` ), chalk.white( `✅` ), + chalk.greenBright( `` ), chalk.gray( `User has valid deviceId` ), + chalk.greenBright( `` ), chalk.gray( `${ verifiedId }` ) ); + } + } + + /* + _GenerateDeviceId › Generate Raw Random Hexadecimal String + + Generates a raw random hexadecimal string using Node.js crypto module. + This is typically used as the random portion of a deviceId. + + @args + len (int) Optional number of bytes to generate. Defaults to 4 bytes. + + @returns + (str) Uppercase hexadecimal string, length = len * 2 characters. + + @usage + const randomHex = Tuner._GenerateDeviceId( 4 ); // 8-character hex string + */ + + static _GenerateDeviceId( len ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + return crypto.randomBytes( len || 4 ).toString( 'hex' ).toUpperCase( ); + } + + /* + GenerateDeviceId › Generate New HDHomeRun Device ID + + Generates a new, properly formatted HDHomeRun deviceId. + + Steps: + - Generates 4 random hexadecimal characters. + - Prepends '105' and appends '0' to form base deviceId. + - Passes baseId to Tuner.FormatDeviceId( ) to ensure correct checksum and 8-character format. + + @args + None + + @returns + (str) A valid, 8-character HDHomeRun deviceId in uppercase hexadecimal. + + @usage + const newDeviceId = Tuner.GenerateDeviceId( ); + */ + + static GenerateDeviceId( ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + const chars = '0123456789ABCDEF'; + let randomHex = ''; + + // generate 4 random hexadecimal chars + for ( let i = 0;i < 4;i++ ) + { + randomHex += chars[Math.floor( Math.random( ) * chars.length )]; + } + + const baseId = '105' + randomHex + '0'; + return this.FormatDeviceId( baseId ); + } + + /* + GetDeviceId › Retrieve Stored HDHomeRun Device ID + + Fetches the current deviceId from persistent storage (via Storage.Get). + + @args + None + + @returns + (str) The current deviceId stored in configuration. + + @usage + const deviceId = await tuner.GetDeviceId( ); + */ + + async GetDeviceId( ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + return Storage.Get( 'deviceId' ); + } + + /* + FormatDeviceId › Validate and Format HDHomeRun Device ID + + Fetches the provided deviceId (or instance default) and ensures it is valid + according to HDHomeRun rules, then returns a properly formatted ID. + + Steps: + - Input must be exactly 8 hexadecimal characters. + - All characters must be 0-9 or A-F/a-f. + - Computes checksum using HDHomeRun-specific lookup table. + - Generates a new deviceId integer with checksum applied. + - Converts back to 8-character uppercase hexadecimal string. + + Logs detailed errors if the input deviceId is invalid. + + @args + deviceid (str) Optional deviceId to format. Defaults to instance deviceId. + + @returns + (str|int) Formatted 8-character hex deviceId, or 0 if input invalid. + + @usage + const formattedId = Tuner.FormatDeviceId( someDeviceId ); + */ + + static FormatDeviceId( deviceid ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + const deviceId = deviceid || this.DeviceId; + + /* + Validate input length + */ + + if ( !deviceId || deviceId.length !== 8 ) + { + Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `HDHomeRun deviceId must be 8 hexadecimals` ), + chalk.redBright( `` ), chalk.gray( `${ deviceId }` ) ); + + return 0; + } + + /* + All chars should be valid hexadecimal + */ + + const hexPattern = /^[0-9A-Fa-f]+$/; + if ( !hexPattern.test( deviceId ) ) + { + Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `HDHomeRun deviceId must contain all hex (0-9, A-F, a-f)` ), + chalk.redBright( `` ), chalk.gray( `${ deviceId }` ) ); + + return 0; + } + + /* + Hex string to integer + */ + + const deviceIdInt = parseInt( deviceId, 16 ); + + /* + Checksum lookup table + */ + + const checksumLookup = + [ + 0xA, 0x5, 0xF, 0x6, 0x7, 0xC, 0x1, 0xB, 0x9, 0x2, 0x8, 0xD, 0x4, 0x3, 0xE, 0x0 + ]; + + /* + Calc checksum + */ + + let checksum = 0; + checksum ^= checksumLookup[( deviceIdInt >> 28 ) & 0x0F]; + checksum ^= ( deviceIdInt >> 24 ) & 0x0F; + checksum ^= checksumLookup[( deviceIdInt >> 20 ) & 0x0F]; + checksum ^= ( deviceIdInt >> 16 ) & 0x0F; + checksum ^= checksumLookup[( deviceIdInt >> 12 ) & 0x0F]; + checksum ^= ( deviceIdInt >> 8 ) & 0x0F; + checksum ^= checksumLookup[( deviceIdInt >> 4 ) & 0x0F]; + + /* + Calc new device ID + */ + + const newDevId = ( deviceIdInt & 0xFFFFFFF0 ) + checksum; + + /* + Convert back to hex string; ensure we get 8 characters with leading zeros; convert to uppercase + */ + + return newDevId.toString( 16 ).toUpperCase( ).padStart( 8, '0' ); + } + + /* + IsDeviceIdValid › Validate HDHomeRun Device ID + + Checks if the current deviceId on this instance is valid according to HDHomeRun rules. + + Validation steps: + - Must be exactly 8 characters long. + - All characters must be hexadecimal (0-9, A-F, a-f). + - Computes checksum using HDHomeRun-specific lookup table; must equal 0. + + Logs detailed errors if the deviceId fails any validation step. + + @returns + (bool) true if deviceId is valid, false otherwise. + + @usage + const isValid = await tuner.IsDeviceIdValid( ); + */ + + async IsDeviceIdValid( ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + /* + Define Hexadecimal charset (0-9, A-F, a-f) + */ + + const hexDigits = new Set( '0123456789ABCDEFabcdef' ); + const deviceId = this.DeviceId; + + /* + Check if device ID is exactly 8 characters + */ + + if ( !deviceId || deviceId.length !== 8 ) + { + Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `HDHomeRun deviceId must be 8 hexadecimals` ), + chalk.redBright( `` ), chalk.gray( `${ deviceId }` ) ); + + return false; + } + + /* + Check if all characters are hexadecimal + */ + + if ( !Array.from( deviceId ).every( ( c ) => hexDigits.has( c ) ) ) + { + Log.error( `hdhr`, chalk.redBright( `[validate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `HDHomeRun deviceId must contain all hex (0-A)` ), + chalk.redBright( `` ), chalk.gray( `${ deviceId }` ) ); + + return false; + } + + /* + Convert hex string to integer (equivalent to int.from_bytes with big endian) + */ + + const deviceIdInt = parseInt( deviceId, 16 ); + + /* + Checksum lookup table + */ + + const checksumLookup = + [ + 0xA, 0x5, 0xF, 0x6, 0x7, 0xC, 0x1, 0xB, 0x9, 0x2, 0x8, 0xD, 0x4, 0x3, 0xE, 0x0 + ]; + + /* + Calc checksum + */ + + let checksum = 0; + checksum ^= checksumLookup[( deviceIdInt >>> 28 ) & 0x0F]; + checksum ^= ( deviceIdInt >>> 24 ) & 0x0F; + checksum ^= checksumLookup[( deviceIdInt >>> 20 ) & 0x0F]; + checksum ^= ( deviceIdInt >>> 16 ) & 0x0F; + checksum ^= checksumLookup[( deviceIdInt >>> 12 ) & 0x0F]; + checksum ^= ( deviceIdInt >>> 8 ) & 0x0F; + checksum ^= checksumLookup[( deviceIdInt >>> 4 ) & 0x0F]; + checksum ^= ( deviceIdInt >>> 0 ) & 0x0F; + + return checksum === 0; + } + + /* + VerifyDeviceId › Validate / Generate Device ID + + Checks if the current deviceId on this instance is valid. + + If missing, uninitialized ('FFFFFFFF'), or fails validation: + a new deviceId is generated via the static Tuner.GenerateDeviceId( ) method. + + New deviceId is saved to persistent storage via Storage.Set( ) and + updated on the instance. + + Function also recursively verifies until a valid deviceId is established. + + @returns + (str) A valid deviceId for this tuner instance. + + @usage + const validId = await tuner.VerifyDeviceId( ); + */ + + async VerifyDeviceId( ) + { + Log.verbose( `func`, chalk.yellow( `[executed]` ), chalk.white( `📣` ), chalk.blueBright( `` ), chalk.gray( `${ Utils.getFuncName( ) }` ) ); + + const deviceId = this.DeviceId; + + if ( !deviceId || deviceId === 'FFFFFFFF' || !await this.IsDeviceIdValid( ) ) + { + const deviceIdNew = Tuner.GenerateDeviceId( ); // static generates a properly formatted ID + if ( deviceId === 'FFFFFFFF' ) + { + Log.info( `conf`, chalk.yellow( `[generate]` ), chalk.white( `📣` ), + chalk.yellow( `` ), chalk.gray( `Generating HDHomeRun deviceId for the first time` ), + chalk.yellow( `` ), chalk.gray( `${ deviceIdNew }` ) ); + } + else + { + Log.error( `conf`, chalk.redBright( `[generate]` ), chalk.white( `❌` ), + chalk.redBright( `` ), chalk.gray( `Invalid deviceId; generating new` ), + chalk.redBright( `` ), chalk.gray( `${ deviceId }` ), + chalk.redBright( `` ), chalk.gray( `${ deviceIdNew }` ) ); + } + + Storage.Set( 'deviceId', deviceIdNew ); // save to JSON via nconf + this.DeviceId = deviceIdNew; // update the instance so validation works + + // verify recursively until valid + const verifiedId = await this.VerifyDeviceId( ); + return verifiedId; + } + + return deviceId; + } +} + +/* + export class + + @image + import Tuner from './classes/Tuner.js'; +*/ + +// eslint-disable-next-line no-restricted-syntax +export default Tuner; diff --git a/tvapp2/classes/Utils.js b/tvapp2/classes/Utils.js new file mode 100644 index 00000000..9b8130c7 --- /dev/null +++ b/tvapp2/classes/Utils.js @@ -0,0 +1,48 @@ +class Utils +{ + + /* + Returns the name of the function that this function was called from. + used for Log.verbose + */ + + static getFuncName() + { + return (new Error()).stack.match(/at (\S+)/g)[1].slice(3); + } + + /* + Returns the name of the constructor that this function was called from. + used for Log.verbose + */ + + static getConstructorName() + { + return (new Error()).stack.match(/new\s+(\w+)/g)[0]; + } + + /* + helper > str2bool + */ + + static str2bool( str ) + { + if ( typeof str === 'string' ) + { + const lower = str.toLowerCase(); + if ([ + '1', 'true', 'yes', 'y', 't' + ].includes( lower ) ) + str = true; + if ([ + '0', 'false', 'no', 'n', 'f' + ].includes( lower ) ) + str = false; + return str; + } + else return Boolean( str ); + } + +} + +export default Utils;