Source: extensions/multistream/multistream.js

/**
 *      StreamRoller Copyright 2023 "SilenusTA https://www.twitch.tv/olddepressedgamer"
 * 
 *      StreamRoller is an all in one streaming solution designed to give a single
 *      'second monitor' control page and allow easy integration for configuring
 *      content (ie. tweets linked to chat, overlays triggered by messages, hue lights
 *      controlled by donations etc)
 * 
 *      This program is free software: you can redistribute it and/or modify
 *      it under the terms of the GNU Affero General Public License as published
 *      by the Free Software Foundation, either version 3 of the License, or
 *      (at your option) any later version.
 * 
 *      This program is distributed in the hope that it will be useful,
 *      but WITHOUT ANY WARRANTY; without even the implied warranty of
 *      MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *      GNU Affero General Public License for more details.
 * 
 *      You should have received a copy of the GNU Affero General Public License
 *      along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
// ============================================================================
//                           IMPORTS/VARIABLES
// ============================================================================
import * as fs from "fs";
import { dirname } from 'path';
import process from 'process';
import { fileURLToPath } from 'url';
import * as logger from "../../backend/data_center/modules/logger.js";
import sr_api from "../../backend/data_center/public/streamroller-message-api.cjs";
import * as ffmpeg from "./ffmpeg.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
// volatile variables
const localConfig = {
    // StreamRoller stuff
    SYSTEM_LOGGING_TAG: "[EXTENSION]",
    DataCenterSocket: null,
    dataCenterApp: null,
    host: null,
    port: null,
    heartBeatTimeout: 5000,
    heartBeatHandle: null,
    SendSettingsWidgetLargeTimerHandle: null,
    serverCredentials: {},

    //extensions basics
    multistreamStartStreaming: "off",
    streamRunning: false,

    // encoders
    ffmpegEncodersString: null,
    Encoders: {},
    hideStreamKey: true, // show stream key on the screen or not
    encoderBuildTimeoutHandle: null, // cancels the encoder builder (in case it gets stuck)
    encoderBuildTimeout: 10000, // give ourselves 10 seconds to run the builder or quit if not finished

    // Error handlers
    // need to add these into the settings to avoid buffer overun exit/issues
    //ie for 50M buffer set this to 50 * 1024 * 1024 / 188
    bufferOverrunFlags: "?overrun_nonfatal=1&fifo_size=50000000&buffer_size=65535",
    // This prevents warnings of header updates (not an issue in streaming)
    HeaderUpdateWarningsFlags1: "-flvflags",
    HeaderUpdateWarningsFlags2: "no_duration_filesize",
    // no buffer for low latency (may cause choppy streams)
    lowLatency1: "-fflags",
    lowLatency2: "+nobuffer"
}

// default empty stream object that wil be used to create new stream objects when we add user expansion of the number of streams available
const defaultEmptyStream =
{
    enabled: "off",
    name: "multistream",
    configurationMode: "Simple",
    Advanced: "",
    URL: "",
    lowLatencyMode: "off",
    twitchBandwidthTest: "off",
    AdditionalURL: "",//goes between URL and StreamKey
    AdditionalParams: "",//goes after StreamKey
    variableBitrate: "off",
    videoEncoder: "",// card dependant, need to check ffmpeg and update as needed
    videoEncoderOption: "",
    videoEncoderOptionParameters: "",
    targetBitrate: "8M",
    resolution: "1664x936",
    framerate: "30",
    keyframeInterval: "60",
    audioEncoder: "aac",
    audioEncoderOption: "",
    audioEncoderOptionParameters: "",
    audioChannels: "2", // number of audio channels output (defaults to source)
    audioBitrate: "128k",
    audioTracks: [],// which audio tracks to include, empty = all
    outputFormat: "flv",
}
// setup some defaults for twitch/youtube to help users get started etc
const defaultTwitchStream =
{
    enabled: "off",
    name: "Twitch",
    configurationMode: "Simple",
    Advanced: "",
    URL: "rtmp://live.twitch.tv/app",
    twitchBandwidthTest: "off",
    AdditionalURL: "",
    AdditionalParams: "?bandwidthtest=true",
    variableBitrate: "off",
    videoEncoder: "h264_nvenc",
    videoEncoderOption: "-preset",
    videoEncoderOptionParameters: "p4",
    targetBitrate: "8M",
    resolution: "1664x936",
    framerate: "30",
    keyframeInterval: "60",
    audioEncoder: "aac",
    audioEncoderOption: "",
    audioEncoderOptionParameters: "",
    audioChannels: "",
    audioBitrate: "128k",
    audioTracks: [],// which audio tracks to include, empty = all
    outputFormat: "flv",
}
const defaultYouTubeStream =
{
    enabled: "off",
    name: "Youtube",
    Advanced: "",
    configurationMode: "Simple",
    URL: "rtmp://b.rtmp.youtube.com/live2",
    twitchBandwidthTest: "off",
    AdditionalURL: "",
    AdditionalParams: "",
    variableBitrate: "off",
    videoEncoder: "h264_nvenc",
    videoEncoderOption: "-preset",
    videoEncoderOptionParameters: "p4",
    targetBitrate: "8M",
    resolution: "1920x1080",
    framerate: "30",
    keyframeInterval: "60",
    audioEncoder: "aac",
    audioEncoderOption: "",
    audioEncoderOptionParameters: "",
    audioChannels: "",
    audioBitrate: "128k",
    audioTracks: [],// which audio tracks to include, empty = all
    outputFormat: "flv",
}

const default_serverConfig = {
    __version__: "0.1",
    multistreamEnabled: "off",
    extensionname: "multistream",
    channel: "MULTISTREAM",
    streams: [defaultTwitchStream, defaultYouTubeStream, defaultTwitchStream, defaultTwitchStream],
    localStreamPort: 1935,
    localStreamURL: "rtmp://localhost:localStreamPort",
    multistream_restore_defaults: "off",
    useStreamRollerFFMPEG: false,
};

let serverConfig = structuredClone(default_serverConfig)
localConfig.serverCredentials =
{
    version: "0.1",
}

const triggersandactions =
{
    extensionname: serverConfig.extensionname,
    description: "Multistream Extension for copying and pasting to get you started faster on writing extensions",
    version: "0.2",
    channel: serverConfig.channel,
    triggers:
        [
            {
                name: "multistreamStreamStarted",
                displaytitle: "A stream was started",
                description: "A Stream has been started to the destination",
                messagetype: "trigger_multistreamStreamStarted",
                parameters: {
                    triggerActionRef: "multistream",
                    triggerActionRef_UIDescription: "Extensionname or User reference that was used to created from this stream, taken action or defaults to multistream",
                    localStreamURI: "",
                    localStreamURI_UIDescription: "The local stream being used",
                }
            },
            {
                name: "multistreamStreamStopped",
                displaytitle: "A stream was stopped",
                description: "A Stream has been stopped",
                messagetype: "trigger_multistreamStreamStopped",
                parameters: {
                    triggerActionRef: "multistream",
                    triggerActionRef_UIDescription: "Extensionname or User reference that was used to created from this stream, taken action or defaults to multistream",
                    localStreamURI: "",
                    localStreamURI_UIDescription: "The local stream that OBS will be outputting to",

                }
            }
        ],
    actions:
        [
            /* {
                 name: "multistreamStartAStream",
                 displaytitle: "Start a stream",
                 description: "Starts a local Stream Using the current local OBS Stream and forwards it to a remote rtmp server",
                 messagetype: "action_multistreamStartStream",
                 parameters: {
                     triggerActionRef: "multistream",
                     triggerActionRef_UIDescription: "Extensionname or User reference that will be passed through to triggers created from this action where possible",
                     localStreamURI: "",
                     localStreamURI_UIDescription: "The local stream that OBS will be outputting to",
                     destinationStreamURL: "description of the message parameter",
                     destinationStreamURL_UIDescription: "The remote stream to send to, twitch,YT etc",
                     command: "",
                     command_UIDescription: "The ffmpeg command"
                 }
             },
             {
                 name: "multistreamStopStream",
                 displaytitle: "Stops a stream",
                 description: "Stops a named stream from running",
                 messagetype: "action_multistreamStopStream",
                 parameters: {
 
                     triggerActionRef: "multistream",
                     triggerActionRef_UIDescription: "Extensionname or User reference that will be passed through to triggers created from this action where possible",
                 }
             }*/
        ],
}
/**
 * 
 * @param {string} app 
 * @param {string} host 
 * @param {string} port 
 * @param {number} heartbeat 
 */
// ============================================================================
//                           FUNCTION: initialise
// ============================================================================
function initialise (app, host, port, heartbeat)
{
    localConfig.dataCenterApp = app
    localConfig.host = host
    localConfig.port = port
    localConfig.heartBeatTimeout = heartbeat;

    try
    {
        localConfig.DataCenterSocket = sr_api.setupConnection(onDataCenterMessage, onDataCenterConnect,
            onDataCenterDisconnect, host, port);
    } catch (err)
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".initialise", "localConfig.DataCenterSocket connection failed:", err);
    }
    ffmpeg.init();
    SetupWebpageServer();
    // TBD needs to only be run when we are enabled ideally rather than at each start of the
    // extension load
    ffmpeg.UpdateEncodersAvailable()
        .then((ret) =>
        {
            localConfig.Encoders = ffmpeg.getEncoders();
            SendSettingsWidgetLarge()
        })
        .catch((err) =>
        {
            // console.log("initialise:Couldn't update Encoders", err)
        })
}
// ============================================================================
//                           FUNCTION: SetupWebpageServer
// ============================================================================
/**
 * Start the webserver. Mostly used to download encoders list to save on 
 * websocket bandwidth
 */
function SetupWebpageServer ()
{
    try
    {
        localConfig.dataCenterApp.get('/multistream/data/encoders.json', (req, res) =>
        {
            fs.readFile(__dirname + '/data/encoders.json', 'utf8', (err, data) =>
            {
                if (err)
                {
                    return res.status(500).json({ error: "Error reading JSON file" });
                }
                res.json(JSON.parse(data)); // Send JSON as API response
            });
        });
    } catch (err)
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME + ".initialise", "initialise failed:", err);
    }
}
// ============================================================================
//                           FUNCTION: onDataCenterDisconnect
// ============================================================================
/**
 * Disconnection message sent from the server
 * @param {String} reason 
 */
function onDataCenterDisconnect (reason)
{
    logger.log(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterDisconnect", reason);
}
// ============================================================================
//                           FUNCTION: onDataCenterConnect
// ============================================================================
/**
 * Connection message handler
 * @param {*} socket 
 */
function onDataCenterConnect (socket)
{
    logger.log(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterConnect", "Creating our channel");

    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket("RequestConfig", serverConfig.extensionname));

    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket("CreateChannel", serverConfig.extensionname, serverConfig.channel)
    );
    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket("RequestCredentials", serverConfig.extensionname));
    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket("JoinChannel", serverConfig.extensionname, "LIVE_PORTAL")
    );
    localConfig.heartBeatHandle = setTimeout(heartBeatCallback, localConfig.heartBeatTimeout)
}
// ============================================================================
//                           FUNCTION: onDataCenterMessage
// ============================================================================
/**
 * receives message from the socket
 * @param {data} server_packet 
 */
function onDataCenterMessage (server_packet)
{
    if (server_packet.type === "ConfigFile")
    {
        if (server_packet.data && server_packet.data.extensionname
            && server_packet.data.extensionname === serverConfig.extensionname)
        {
            let ffmpegChanged = false;
            let configSubVersions = 0;
            let defaultSubVersions = default_serverConfig.__version__.split('.');
            // should only be hit on very first run when there is no saved data file
            if (!server_packet.data.__version__)
            {
                serverConfig = structuredClone(default_serverConfig);
                SaveConfigToServer();
            }
            configSubVersions = server_packet.data.__version__.split('.')

            if (configSubVersions[0] != defaultSubVersions[0])
            {
                serverConfig = structuredClone(default_serverConfig);
                console.log("\x1b[31m" + serverConfig.extensionname + " ConfigFile Updated", "The config file has been Updated to the latest version v" + default_serverConfig.__version__ + ". Your settings may have changed" + "\x1b[0m");
                SaveConfigToServer();
                ffmpegChanged = true;
            }
            else if (configSubVersions[1] != defaultSubVersions[1])
            {
                serverConfig = { ...default_serverConfig, ...server_packet.data };
                serverConfig.__version__ = default_serverConfig.__version__;
                console.log(serverConfig.extensionname + " ConfigFile Updated", "The config file has been Updated to the latest version v" + default_serverConfig.__version__);
                SaveConfigToServer();
                ffmpegChanged = true;
            }
            else
            {
                // check if we have turned on the extension
                if (server_packet.multistreamEnabled == "on" && server_packet.multistreamEnabled == "off")
                    ffmpegChanged = true;
                serverConfig = structuredClone(server_packet.data);
                SaveConfigToServer();
            }
            ffmpeg.useStreamRollerFfmpeg(serverConfig.useStreamRollerFFMPEG)
            SendSettingsWidgetSmall();
            if (ffmpegChanged)
                checkFFMPEGAvailabilities();
            else
                SendSettingsWidgetLarge();
        }
    }
    else if (server_packet.type === "CredentialsFile")
    {
        if (server_packet.to === serverConfig.extensionname && server_packet.data != "")
        {
            localConfig.serverCredentials = structuredClone(server_packet.data);
            SendSettingsWidgetSmall();
            SendSettingsWidgetLarge();
        }
        else
        {
            logger.warn(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage",
                serverConfig.extensionname + " CredentialsFile", "Credential file is empty.");
        }
    }
    // This is a message from an extension. the content format will be described by the extension
    else if (server_packet.type === "ExtensionMessage")
    {
        let extension_packet = server_packet.data;

        if (extension_packet.type === "RequestSettingsWidgetSmallCode")
            SendSettingsWidgetSmall();
        else if (extension_packet.type === "RequestSettingsWidgetLargeCode")
            SendSettingsWidgetLarge();
        else if (extension_packet.type === "SettingsWidgetSmallData")
        {
            if (extension_packet.data.extensionname === serverConfig.extensionname)
                parseSettingsWidgetSmall(extension_packet.data)
        }
        else if (extension_packet.type === "SettingsWidgetLargeData")
        {
            if (extension_packet.to === serverConfig.extensionname)
                parseSettingsWidgetLarge(extension_packet.data)
        }
        else if (extension_packet.type === "SendTriggerAndActions")
        {
            sr_api.sendMessage(localConfig.DataCenterSocket,
                sr_api.ServerPacket("ExtensionMessage",
                    serverConfig.extensionname,
                    sr_api.ExtensionPacket(
                        "TriggerAndActions",
                        serverConfig.extensionname,
                        triggersandactions,
                        "",
                        server_packet.from
                    ),
                    "",
                    server_packet.from
                )
            )
        }
        else if (extension_packet.type === "action_multistreamStartStream")
        {
            console.log("TBD:action_multistreamStartStream called with", extension_packet.data)
        }
        else if (extension_packet.type === "action_multistreamStopStream")
        {
            console.log("TBD:action_multistreamStopStream called with", extension_packet.data)
            stopStream(extension_packet.data.triggerActionRef,
                extension_packet.data.streamName)
        }
        else
            logger.log(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage", "received unhandled ExtensionMessage ", server_packet);

    }
    else if (server_packet.type === "UnknownChannel")
    {
        logger.info(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage", "Channel " + server_packet.data + " doesn't exist, scheduling rejoin");
        setTimeout(() =>
        {
            sr_api.sendMessage(localConfig.DataCenterSocket,
                sr_api.ServerPacket(
                    "JoinChannel", serverConfig.extensionname, server_packet.data
                ));
        }, 5000);
    }
    else if (server_packet.type === "ChannelData")
    {
        //let extension_packet = server_packet.data;
    }
    else if (server_packet.type === "InvalidMessage")
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage",
            "InvalidMessage ", server_packet.data.error, server_packet);
    }
    else if (server_packet.type === "LoggingLevel")
    {
        logger.setLoggingLevel(server_packet.data)
    }
}

// ============================================================================
//                           FUNCTION: parseSettingsWidgetSmall
// ============================================================================
/**
 * Our small settings widget was submitted
 * @param {object} extension_data 
 */
function parseSettingsWidgetSmall (extension_data)
{
    let extensionEnabledChanged = false
    let streamOnOffChanged = false

    // multistreamStartStreaming is stored in localConfig as we should never startup StreamRoller with it enabled so it defaults to off on restart
    if (extension_data.multistreamStartStreaming == "on")
    {
        // have have turned the streaming on
        if (localConfig.multistreamStartStreaming == "off")
            streamOnOffChanged = true
        localConfig.multistreamStartStreaming = "on"
    }
    else
    {
        if (localConfig.multistreamStartStreaming == "on")
            streamOnOffChanged = true;
        localConfig.multistreamStartStreaming = "off"
    }

    // turn them off here/ they will be turned back on if checkboxes are set in the ui
    serverConfig.streams.forEach((stream, i) =>
    {
        stream.enabled = "off";
    })
    // have we just turned on the extension
    if (extension_data.multistreamEnabled == "on" && serverConfig.multistreamEnabled == "off")
        extensionEnabledChanged = true;
    // turned off?
    else if (!extension_data.multistreamEnabled && serverConfig.multistreamEnabled == "on")
        extensionEnabledChanged = true;

    // set any checkbox data to off at this point as it won't appear in extension_data if turned off
    serverConfig.multistreamEnabled = "off";

    // check data received
    for (const [key, value] of Object.entries(extension_data))
    {
        // attempt to get an id value
        // take the last 2 chars, remove the underscore if there is one.
        // this limits us to 100 streams (as will _99 will work with this method)
        var streamId = key.substring(key.length - 2).replace("_", "")
        if (serverConfig.streams[streamId])
        {
            var variableName = key.replace("multistream_", "").replace("_" + streamId, "")
            if (serverConfig.streams[streamId][variableName])
                serverConfig.streams[streamId][variableName] = value
        }
        else
        {
            if (serverConfig[key])
                serverConfig[key] = value;
        }
    }
    SaveConfigToServer();

    // extension turned on
    if (extensionEnabledChanged)
    {
        if (serverConfig.multistreamEnabled == "on")
        {
            if (localConfig.Encoders.videoEncoders == {} || localConfig.Encoders.audioEncoders == {})
                //extension has just been turned on
                checkFFMPEGAvailabilities();
        }
        else
        {
            localConfig.multistreamStartStreaming = "off"
            stopStream()
        }
    }
    // stream start toggled
    if (streamOnOffChanged)
    {
        // don't start unless extension is turned on
        if (localConfig.multistreamStartStreaming == "on" && serverConfig.multistreamEnabled == "on")
            startStream("multistream")
        else
        {
            localConfig.multistreamStartStreaming = "off"
            stopStream()
        }
    }

    //update anyone who is showing our code at the moment
    SendSettingsWidgetSmall();
    SendSettingsWidgetLarge();
}
// ============================================================================
//                           FUNCTION: parseSettingsWidgetLarge
// ============================================================================
/**
 * Our large settings widget was submitted
 * @param {object} extension_data 
 */
function parseSettingsWidgetLarge (extension_data)
{
    let credsChanged = false;
    let ffmpegChanged = false;
    // reset to defaults
    if (extension_data.multistream_restore_defaults == "on")
    {
        serverConfig = structuredClone(default_serverConfig);
        SendSettingsWidgetLarge();
    }
    else
    {
        // clean up streams and add streamKey/ffmpeg.exe to use
        serverConfig.streams.forEach((stream, i) =>
        {
            stream.enabled = "off";
            stream.variableBitrate = "off";
            stream.twitchBandwidthTest = "off";
            stream.audioTracks = [];// clear out audioTracks as they are added below
            // check credentials
            if (extension_data[`multistreamStream${i}StreamKey`] != localConfig.serverCredentials[`multistreamStream${i}StreamKey`])
            {
                localConfig.serverCredentials[`multistreamStream${i}StreamKey`] = extension_data[`multistreamStream${i}StreamKey`];
                credsChanged = true;
            }
            if ((extension_data.multistreamffmpegpicker == "1") != serverConfig.useStreamRollerFFMPEG)

                ffmpegChanged = true;
        })

        if (credsChanged)
            saveCredentialsToServer()

        // check the config values
        for (const [key, value] of Object.entries(extension_data))
        {
            // attempt to get an id value
            // take the last 2 chars, remove the underscore if there is one.
            // this limits us to 100 streams (as will _99 will work with this method)
            var streamId = "";
            if (key.indexOf("audioTrack") > 0)
            {
                // get the track id
                let trackId = key.substring(key.length - 2).replace("_", "")
                // remove the track id from the string
                var temp = key.substring(0, key.length - (trackId.length + 1))
                streamId = temp.substring(temp.length - 2).replace("_", "")
                serverConfig.streams[streamId].audioTracks.push(trackId)
            }
            else
            {
                streamId = key.substring(key.length - 2).replace("_", "")
                if (serverConfig.streams[streamId])
                {
                    var variableName = key.replace("multistream_", "").replace("_" + streamId, "")
                    serverConfig.streams[streamId][variableName] = value
                }
                else
                {
                    if (serverConfig[key])
                        serverConfig[key] = value;
                }
            }
        }
        serverConfig.useStreamRollerFFMPEG = (extension_data.multistreamffmpegpicker == "1");
        ffmpeg.useStreamRollerFfmpeg((extension_data.multistreamffmpegpicker == "1"))

        // change "on" to true in out settings
        if (extension_data["multistream_lowLatencyMode"] == "on")
            serverConfig.lowLatencyMode = true;
        else
            serverConfig.lowLatencyMode = false;

        if (extension_data["multistream_hideStreamKey"] == "on")
            localConfig.hideStreamKey = true;
        else
            localConfig.hideStreamKey = false;

        let ffmpegDebug = {};
        if (extension_data["multistream_DEBUG_FFMPEG"] == "on")
            ffmpegDebug.DEBUG_FFMPEG = true;
        else
            ffmpegDebug.DEBUG_FFMPEG = false;

        if (extension_data["multistream_DEBUG_FFMPEG_STDERR"] == "on")
            ffmpegDebug.DEBUG_FFMPEG_STDERR = true;
        else
            ffmpegDebug.DEBUG_FFMPEG_STDERR = false;

        if (extension_data["multistream_DEBUG_FFMPEG_STDOUT"] == "on")
            ffmpegDebug.DEBUG_FFMPEG_STDOUT = true;
        else
            ffmpegDebug.DEBUG_FFMPEG_STDOUT = false;
        ffmpeg.setDebug(ffmpegDebug);

        // if we want to use our installed version but don't have it then call the download function
        if (serverConfig.useStreamRollerFFMPEG && !ffmpeg.getInstalledFFMPEGs().streamRollerFfmpegInstalled)
        {
            ffmpeg.downloadFFMPEG()
            let counter = 0
            // wait for download to finish
            let downloadHandle = setInterval(() =>
            {
                if (ffmpeg.getFFMPEGCommand() != "" || counter++ > 60)
                {
                    clearInterval(downloadHandle)
                    checkFFMPEGAvailabilities()
                }
            }, 1000);
        }
        else if (ffmpegChanged)
            checkFFMPEGAvailabilities();
        else
            SendSettingsWidgetLarge();

        SaveConfigToServer();
    }

    //update anyone who is showing our code at the moment
    SendSettingsWidgetSmall();
    // if changed we will update our Availabilities and this will send this out anyway

}
// ===========================================================================
//                           FUNCTION: SendSettingsWidgetSmall
// ===========================================================================
/**
 * update and send out our small settings widget
 */
function SendSettingsWidgetSmall ()
{
    fs.readFile(__dirname + '/multistreamsettingswidgetsmall.html', function (err, filedata)
    {
        if (err)
            logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
                ".SendSettingsWidgetSmall", "failed to load modal", err);
        else
        {
            let modalString = filedata.toString();
            // replace our serverConfig variables in the code
            for (const [key, value] of Object.entries(serverConfig))
            {
                if (value === "on")
                    modalString = modalString.replace(key + "checked", "checked");
                else if (typeof (value) == "string")
                    modalString = modalString.replace(key + "text", value);
            }

            // update credential boxes
            if (localConfig.multistreamStartStreaming == "on")
                modalString = modalString.replace("multistreamStartStreamingchecked", "checked");
            let streamsHtml = "<h4>Streams<h4>"
            streamsHtml = "<BR>Setup streams in the large settings page. Select here to enable/disable those streams"
            serverConfig.streams.forEach((stream, i) =>
            {
                streamsHtml += "<HR>";
                streamsHtml += `<h4>${i + 1}: ${stream.name}</h4>`;
                // stream enabled
                streamsHtml += createCheckBox("Enabled/Disabled", `multistream_enabled_${i}`, stream.enabled == "on");
                streamsHtml += "<BR>";
                streamsHtml += "<h4>Settings</H4>"
                for (const [key, value] of Object.entries(stream))
                {
                    streamsHtml += `${key} = ${value}, `;
                }
            })
            modalString = modalString.replace("multistreamStreamHTML", streamsHtml);

            sr_api.sendMessage(localConfig.DataCenterSocket,
                sr_api.ServerPacket(
                    "ChannelData",
                    serverConfig.extensionname,
                    sr_api.ExtensionPacket(
                        "SettingsWidgetSmallCode",
                        serverConfig.extensionname,
                        modalString,
                        serverConfig.channel,
                        "",
                    ),
                    serverConfig.channel,
                ))
        }
    });
}
// ===========================================================================
//                           FUNCTION: SendSettingsWidgetLarge
// ===========================================================================
/**
 */
function SendSettingsWidgetLarge ()
{
    // if encoders are updating don't send out a widget page yet. start a timer to check for when they have finished updating to send out the widget
    let ffmpegBusyFlags = ffmpeg.ffmpegBusyFlags();
    // check if ffmpeg is currently updating
    if (ffmpegBusyFlags.getEncoders || ffmpegBusyFlags.getEncoderOptions > 0)
    {
        // clear previous timers (has the benefit of squashing multiple requests to sending one result out)
        clearTimeout(localConfig.SendSettingsWidgetLargeTimerHandle)
        localConfig.SendSettingsWidgetLargeTimerHandle = setTimeout(() =>
        {
            SendSettingsWidgetLarge()
        }, 500);
        return;
    }
    // if we receive another request timer might still be running but not busy so cancel the timer and send it out now
    clearTimeout(localConfig.SendSettingsWidgetLargeTimerHandle)
    localConfig.SendSettingsWidgetLargeTimerHandle = null;
    // read our modal file
    fs.readFile(__dirname + "/multistreamsettingswidgetlarge.html", function (err, filedata)
    {
        if (err)
            logger.err(localConfig.SYSTEM_LOGGING_TAG + localConfig.EXTENSION_NAME +
                ".SendSettingsWidgetLarge", "failed to load modal", err);
        //throw err;
        else
        {
            let modalString = filedata.toString();
            modalString = modalString.replace("multistream_localStreamURL",
                serverConfig.localStreamURL);

            /* replacement text for variables */
            const ffmpegExe = (__dirname + "\\bin\\ffmpeg.exe").replaceAll("\\", "\\\\")
            modalString = modalString.replace("currentCommand: replace",
                "currentCommand: '" + ffmpegExe + " " + buildFFMPEGArgs(localConfig.hideStreamKey).join(" ") + "'")
            modalString = modalString.replace("multistreamtextcurrentCommand", ffmpeg.getFFMPEGCommand().replaceAll("\\\\", "\\") + " " + buildFFMPEGArgs(localConfig.hideStreamKey).join(" "))
            modalString = modalString.replace("ffmpegExe: replace",
                "ffmpegExe: '" + ffmpegExe + "'")
            modalString = modalString.replace("InstalledFFMPEGs: replace",
                "InstalledFFMPEGs: " + JSON.stringify(ffmpeg.getInstalledFFMPEGs()))
            modalString = modalString.replace("useStreamRollerFFMPEG: replace",
                "useStreamRollerFFMPEG: " + serverConfig.useStreamRollerFFMPEG)
            modalString = modalString.replace("streams: replace",
                "streams:" + JSON.stringify(serverConfig.streams))
            modalString = modalString.replace("serverCredentials: replace",
                "serverCredentials: " + JSON.stringify(localConfig.serverCredentials))
            modalString = modalString.replace("ffmpegDebug: replace",
                "ffmpegDebug: " + JSON.stringify(ffmpeg.getDebug()))
            modalString = modalString.replace("hideStreamKey: replace",
                "hideStreamKey: " + localConfig.hideStreamKey);
            modalString = modalString.replace("lowLatencyMode: replace",
                "lowLatencyMode: " + serverConfig.lowLatencyMode)

            // send the modified modal data to the server
            sr_api.sendMessage(localConfig.DataCenterSocket,
                sr_api.ServerPacket(
                    "ExtensionMessage", // this type of message is just forwarded on to the extension
                    serverConfig.extensionname,
                    sr_api.ExtensionPacket(
                        "SettingsWidgetLargeCode", // message type
                        serverConfig.extensionname, //our name
                        modalString,// data
                        serverConfig.channel,
                        "",
                    ),
                    serverConfig.channel,
                    ""
                ))
        }
    });
}
// ============================================================================
//                           FUNCTION: SaveConfigToServer
// ============================================================================
/**
 * Sends our config to the server to be saved for next time we run
 */
function SaveConfigToServer ()
{
    sr_api.sendMessage(localConfig.DataCenterSocket, sr_api.ServerPacket
        ("SaveConfig",
            serverConfig.extensionname,
            serverConfig))
}
// ============================================================================
//                           FUNCTION: saveCredentialsToServer
// ============================================================================
/**
 * Sends our credentials to the server to be saved for next time we run
 */
function saveCredentialsToServer ()
{
    for (var c in localConfig.serverCredentials)
    {
        let creds = {
            ExtensionName: serverConfig.extensionname,
            CredentialName: c,
            CredentialValue: localConfig.serverCredentials[c]
        }
        // can't set empty credentials
        if (localConfig.serverCredentials[c] == "")
            creds.CredentialValue = "Empty"
        sr_api.sendMessage(localConfig.DataCenterSocket,
            sr_api.ServerPacket(
                "UpdateCredentials",
                serverConfig.extensionname,
                creds,
            ));
    }
}
// ============================================================================
//                           FUNCTION: startStream
// ============================================================================
/**
 * 
 * @param {string} ref identifier that is carried over from an action or defaulted
 */
function startStream (ref = "multistream")
{
    if (serverConfig.multistreamEnabled != "on")
    {
        console.log("Multistream Extension turn off when trying to start a stream")
        localConfig.multistreamStartStreaming = "off"
        return;
    }
    if (localConfig.ffmpegHandle && localConfig.ffmpegHandle != null)
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
            ".startStream", "A stream is already running");
        return;
    }
    localConfig.ffmpegHandle = ffmpeg.runFFMPEG(
        ffmpeg.getFFMPEGCommand(),
        buildFFMPEGArgs(false),
        //{ shell: true },
        {},
        function processCB (handle)
        {
            // console.log("handle", JSON.stringify(handle, null, 2))
        },
        function streamStarted (handle)
        {
            if (!localConfig.streamRunning)
            {
                localConfig.streamRunning = true;
                sendStreamStartedTrigger(ref);
                //sendOBSStartStreamingAction(ref)
                sendOBSStartRecordingAction(ref);
            }
            // console.log("handle", JSON.stringify(handle, null, 2))
        },
        function streamFinished (data = "", out = "", err = "")
        {
            if (err && err != "")
                console.log("StreamFinished: ", err)
            localConfig.streamRunning = false;
            sendStreamStoppedTrigger();
            //sendOBSStopStreamingAction(ref);
            sendOBSStopRecordingAction(ref);
            localConfig.ffmpegHandle = null
            localConfig.multistreamStartStreaming = "off"
        }
    )
}
// ============================================================================
//                           FUNCTION: buildFFMPEGArgs
// ============================================================================

/**
 * Builds the argument string for ffmpeg
 *  @param {boolean} hideStreamKey //if true replaces keys with placeholders
 * @returns ffmpeg args for the given stream
 */
function buildFFMPEGArgs (hideStreamKey = true)
{
    let OBSUrl = serverConfig.localStreamURL.replace("localStreamPort", serverConfig.localStreamPort);
    let ffmpegArgs =
        [
            //"-listen", "1",  // Makes FFmpeg listen for incoming RTMP stream
            "-i",
            OBSUrl //+ localConfig.serverCredentials.localStreamKey,
        ]
    if (serverConfig.lowLatencyMode)
    {
        ffmpegArgs.push(localConfig.lowLatency1);
        ffmpegArgs.push(localConfig.lowLatency2);
    }
    serverConfig.streams.forEach((stream, i) =>
    {
        ffmpegArgs = buildStreamParams(ffmpegArgs, stream, i, hideStreamKey)
    });
    return ffmpegArgs;
}
// ============================================================================
//                           FUNCTION: stopStream
// ============================================================================
function buildStreamParams (ffmpegArgs, stream, streamIndex, hideStreamKey = true)
{
    if (stream.enabled == "on")
    {
        // Simple mode
        if (stream.configurationMode == "Simple")
            ffmpegArgs = buildSimpleStreamArgs(ffmpegArgs, stream, streamIndex, hideStreamKey)
        else if (stream.configurationMode == "Configurable")
            ffmpegArgs = buildConfigurableStreamArgs(ffmpegArgs, stream, streamIndex, hideStreamKey)
        else if (stream.configurationMode == "Advanced") 
        {
            let params = stream.Advanced.split(" ");
            params.forEach((item, index) =>
            {
                ffmpegArgs.push(item);
            });
        }
        else
            logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
                ".buildFFMPEGArgs", "No stream.configurationMode", streamIndex, stream.configurationMode);
    }
    return ffmpegArgs;
}
// ============================================================================
//                           FUNCTION: buildSimpleStreamArgs
// ============================================================================
function buildSimpleStreamArgs (ffmpegArgs, stream, streamIndex, hideStreamKey)
{

    //always take the complete video
    ffmpegArgs.push("-map"); ffmpegArgs.push("0:v");

    if (stream.audioTracks && stream.audioTracks.length > 0)
    {
        ffmpegArgs = buildAudioArgs(ffmpegArgs, stream);
        ffmpegArgs.push("-c:v"); ffmpegArgs.push("copy");
    }
    else
    {
        ffmpegArgs.push("-map"); ffmpegArgs.push("0:a");
        ffmpegArgs.push("-c:v"); ffmpegArgs.push("copy");
        ffmpegArgs.push("-c:a"); ffmpegArgs.push("copy");
    }

    ffmpegArgs.push(localConfig.HeaderUpdateWarningsFlags1);
    ffmpegArgs.push(localConfig.HeaderUpdateWarningsFlags2);
    ffmpegArgs.push("-f"); ffmpegArgs.push("flv");
    if (stream.URL && stream.URL != "")
    {
        let twitchBandwidthTest = "";
        if (stream.twitchBandwidthTest == "on")
            twitchBandwidthTest = "?bandwidthtest=true"

        if (hideStreamKey)
            ffmpegArgs.push(stream.URL + localConfig.bufferOverrunFlags + "/STREAM_KEY_GOES_HERE" + twitchBandwidthTest);
        else
            ffmpegArgs.push(stream.URL + localConfig.bufferOverrunFlags + "/" + localConfig.serverCredentials[`multistreamStream${streamIndex}StreamKey`] + twitchBandwidthTest);
    }
    else
        console.log(`Multistream: missing URL for stream[${streamIndex}] ${stream.name}`)
    return ffmpegArgs;
}
// ============================================================================
//                           FUNCTION: buildConfigurableStreamArgs
// ============================================================================
function buildConfigurableStreamArgs (ffmpegArgs, stream, streamIndex, hideStreamKey)
{
    //always take the complete video
    ffmpegArgs.push("-map"); ffmpegArgs.push("0:v");
    if (stream.audioTracks && stream.audioTracks.length > 0)
    {
        stream.audioTracks.forEach((item, index) =>
        {
            ffmpegArgs.push("-map"); ffmpegArgs.push("0:a:" + item);
        });
    }
    else
    {
        ffmpegArgs.push("-map"); ffmpegArgs.push("0:a");
    }
    if (stream.videoEncoder && stream.videoEncoder != "")
    {
        ffmpegArgs.push("-c:v");
        ffmpegArgs.push(stream.videoEncoder);
        if (stream.videoEncoderOption && stream.videoEncoderOption != "" && stream.videoEncoderOption != "none")
        {
            ffmpegArgs.push(stream.videoEncoderOption);
            if (stream.videoEncoderOptionParameters && stream.videoEncoderOptionParameters != "" && stream.videoEncoderOptionParameters != "EMPTY")
            {
                let array = stream.videoEncoderOptionParameters.split(" ")
                array.forEach((value, i) =>
                {
                    //ffmpegArgs.push("-preset"); 
                    ffmpegArgs.push(value);
                })
            }
        }
    }
    //set the basic options
    if (stream.variableBitrate == "on")
    { ffmpegArgs.push("-rc"); ffmpegArgs.push("vbr"); }//variable bitrate
    else
    { ffmpegArgs.push("-rc"); ffmpegArgs.push("cbr"); }//constant bitrate
    if (stream.targetBitrate && stream.targetBitrate != "")
    { ffmpegArgs.push("-b:v"); ffmpegArgs.push(stream.targetBitrate); }
    if (stream.resolution && stream.resolution != "")
    { ffmpegArgs.push("-s"); ffmpegArgs.push(stream.resolution); }
    if (stream.framerate && stream.framerate != "")
    { ffmpegArgs.push("-r"); ffmpegArgs.push(stream.framerate); }//use a standard frame rate
    if (stream.keyframeInterval && stream.keyframeInterval != "")
    { ffmpegArgs.push("-g"); ffmpegArgs.push(stream.keyframeInterval); }
    if (stream.audioEncoder && stream.audioEncoder != "")
    {
        ffmpegArgs.push("-c:a");
        ffmpegArgs.push(stream.audioEncoder);
        if (stream.audioEncoderOption && stream.audioEncoderOption != "" && stream.audioEncoderOption != "none")
        {
            ffmpegArgs.push(stream.audioEncoderOption);
            if (stream.audioEncoderOptionParameters && stream.audioEncoderOptionParameters != "" && stream.audioEncoderOptionParameters != "EMPTY")
            {
                let array = stream.audioEncoderOptionParameters.split(" ")
                array.forEach((value, i) =>
                {
                    ffmpegArgs.push(value);
                })
            }
        }
    }
    if (stream.audioChannels && stream.audioChannels != "")
    { ffmpegArgs.push("-ac"); ffmpegArgs.push(stream.audioChannels); } // stereo sound
    //ffmpegArgs.push("-crf"); ffmpegArgs.push("18"); // set a constant rate factor to 'visually lossless'
    if (stream.audioBitrate && stream.audioBitrate != "")
    { ffmpegArgs.push("-b:a"); ffmpegArgs.push(stream.audioBitrate); }
    if (stream.outputFormat && stream.outputFormat != "")
    { ffmpegArgs.push("-f"); ffmpegArgs.push(stream.outputFormat) }

    if (stream.URL && stream.URL != "")
    {
        if (hideStreamKey)
            ffmpegArgs.push(stream.URL + stream.AdditionalURL + "/STREAM_KEY_GOES_HERE" + stream.AdditionalParams);
        else
            ffmpegArgs.push(stream.URL + stream.AdditionalURL + "/" + localConfig.serverCredentials[`multistreamStream${streamIndex}StreamKey`] + stream.AdditionalParams);
    }
    else
        console.log(`Multistream: missing URL for stream ${streamIndex}: ${stream.name}`)
    return ffmpegArgs;
}
// ============================================================================
//                           FUNCTION: buildAudioArgs
// ============================================================================
function buildAudioArgs (ffmpegArgs, stream)
{
    //-filter_complex "[0:a:0][0:a:1][0:a:2]amix=inputs=3:duration=longest[aout]" -map 0:v -map "[aout]"
    let trackCount = stream.audioTracks.length
    let mappings = ''
    ffmpegArgs.push("-filter_complex");

    stream.audioTracks.forEach((item, index) =>
    {
        mappings += `[0:a:${item}]`
    });
    mappings += "amix=inputs=" + trackCount + ":duration=longest[aout]"
    ffmpegArgs.push(mappings);
    ffmpegArgs.push("-map");
    ffmpegArgs.push("[aout]");
    return ffmpegArgs;
}
// ============================================================================
//                           FUNCTION: stopStream
// ============================================================================
/**
 * Stop streaming
 */
function stopStream ()
{
    if (localConfig.ffmpegHandle)
    {
        sendStreamStoppedTrigger()
        killProcess()
        //localConfig.ffmpegHandle = null
        localConfig.multistreamStartStreaming = "off"
    }
}
// ============================================================================
//                           FUNCTION: killProcess
// ============================================================================
/**
 * kills the current ffmpeg process
 */
function killProcess ()
{
    if (!localConfig.ffmpegHandle)
    {
        logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
            ".killProcess", "No stream found");
        return;
    }
    else if (localConfig.ffmpegHandle)
        localConfig.ffmpegHandle.kill('SIGINT');
}
// ============================================================================
//                           process terminate Callback
// ============================================================================
// Graceful shutdown on Ctrl+C/exit
process.on("SIGINT", () =>
{
    if (localConfig.ffmpegHandle)
        localConfig.ffmpegHandle.kill("SIGINT"); // Sends SIGINT to FFmpeg
    process.exit();
});

// ============================================================================
//                           FUNCTION: sendOBSStartStreamingAction
// ============================================================================
/**
 * tells OBS to start streaming
 * @param {string} triggerActionRef pass through reference from action or "multistream"
 */
function sendOBSStartStreamingAction (triggerActionRef = "multistream")
{
    let action = {
        messagetype: "action_StartStream",
        to: "obs",
        data: {
            triggerActionRef: triggerActionRef,
        }
    }
    sendAction(action);
}
// ============================================================================
//                           FUNCTION: sendOBSStopStreamingAction
// ============================================================================
/**
 * 
 * @param {string} triggerActionRef pass through reference from action or "multistream"
 */
function sendOBSStopStreamingAction (triggerActionRef = "multistream")
{
    let action = {
        messagetype: "action_StopStream",
        to: "obs",
        data: {
            triggerActionRef: triggerActionRef,
        }
    }
    sendAction(action);
}
// ============================================================================
//                           FUNCTION: sendOBSStartRecordingAction
// ============================================================================
/**
 * tells OBS to start Recording
 * @param {string} triggerActionRef pass through reference from action or "multistream"
 */
function sendOBSStartRecordingAction (triggerActionRef = "multistream")
{
    let action = {
        messagetype: "action_StartRecording",
        to: "obs",
        data: {
            triggerActionRef: triggerActionRef,
        }
    }
    sendAction(action);
}
// ============================================================================
//                           FUNCTION: sendOBSStopRecordingAction
// ============================================================================
/**
 * 
 * @param {string} triggerActionRef pass through reference from action or "multistream"
 */
function sendOBSStopRecordingAction (triggerActionRef = "multistream")
{
    let action = {
        messagetype: "action_StopRecording",
        to: "obs",
        data: {
            triggerActionRef: triggerActionRef,
        }
    }
    sendAction(action);
}
// ============================================================================
//                           FUNCTION: sendStreamStartedTrigger
// ============================================================================
/**
 * 
 * @param {string} triggerActionRef pass through reference from action or "multistream"
 */
function sendStreamStartedTrigger (triggerActionRef = "multistream")
{
    let trigger = findTriggerByMessageType("trigger_multistreamStreamStarted");
    trigger.parameters =
    {
        triggerActionRef: triggerActionRef,
        localStreamURI: serverConfig.localStreamURL.replace("localStreamPort", serverConfig.localStreamPort),
    }
    sendTrigger(trigger)
}
// ============================================================================
//                           FUNCTION: sendStreamStoppedTrigger
// ============================================================================
/**
 * 
 * @param {string} triggerActionRef pass through reference from action or "multistream"
 */
function sendStreamStoppedTrigger (triggerActionRef)
{
    let trigger = findTriggerByMessageType("trigger_multistreamStreamStopped");
    trigger.parameters =
    {
        triggerActionRef: triggerActionRef,
        localStreamURI: serverConfig.localStreamURL.replace("localStreamPort", serverConfig.localStreamPort)
    }
    sendTrigger(trigger)
}
// ============================================================================
//                           FUNCTION: findTriggerByMessageType
// ============================================================================
/**
 * Finds the trigger using the passed messagetype
 * @param {string} messagetype 
 * @returns trigger
 */
function findTriggerByMessageType (messagetype)
{
    for (let i = 0; i < triggersandactions.triggers.length; i++)
    {
        if (triggersandactions.triggers[i].messagetype.toLowerCase() == messagetype.toLowerCase())
            return triggersandactions.triggers[i];
    }
    logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
        ".findTriggerByMessageType", "failed to find trigger", messagetype);
}

// ===========================================================================
//                           FUNCTION: sendTrigger
// ===========================================================================
/**
 * sends an action out.
 * @param {object} action {messagetype:..', to:..., data: {<< action params >>}}
 */
function sendAction (action)
{
    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket(
            'ExtensionMessage',
            serverConfig.extensionname,
            sr_api.ExtensionPacket(
                action.messagetype,
                serverConfig.extensionname,
                action.data,
                "",
                action.to),
            "",
            action.to
        )
    );
}
// ===========================================================================
//                           FUNCTION: sendTrigger
// ===========================================================================
/**
 * Sends the given trigger or action out on our channel if to is ""
 * or sends to the extension as an extension message if to specifies extension name
 * @param {object} data 
 */
function sendTrigger (data)
{
    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket(
            'ChannelData',
            serverConfig.extensionname,
            sr_api.ExtensionPacket(
                data.messagetype,
                serverConfig.extensionname,
                data,
                serverConfig.channel,
                ''),
            serverConfig.channel,
            ''
        )
    );

}

// ===========================================================================
//                           FUNCTION: createCheckBox
// ===========================================================================
/**
 * 
 * @param {string} description 
 * @param {string} name 
 * @param {boolean} checked 
 * @returns html code of the checkbox
 */
function createCheckBox (description, name, checked)
{
    let checkedtext = "";
    if (checked)
        checkedtext = "checked";
    return `
    <div class="form-check form-check-inline py-2">
        <input class="form-check-input" name="${name}" type="checkbox" id="${name}" ${checkedtext}>
        <label class="form-check-label" for="${name}">&nbsp;${description}</label>
    </div>`
}
// ############################################################################
//                            GET FFMPEG INFO
// Stuff like video encoders available etc
// ############################################################################
// ============================================================================
//                      FUNCTION: checkFFMPEGAvailabilities
// ============================================================================
/**
 * Checks and updates availabilities of FFMPEG with current settings
 * and builds the encoders lists
 */
function checkFFMPEGAvailabilities ()
{
    //ffmpeg.checkFFMPEGInstall()
    let ffmpegExe = ffmpeg.getFFMPEGCommand();
    // if we don't have a command we can't run so just send the widget
    if (!ffmpegExe)
    {
        SendSettingsWidgetLarge();
        return;
    }
    // get our video encoders
    ffmpeg.UpdateEncodersAvailable()
        .then((encoders) =>
        {
            SendSettingsWidgetLarge();
        })
        .catch((err) =>
        {
            console.log("checkFFMPEGAvailabilities:Couldn't update Encoders", err)
            SendSettingsWidgetLarge();
        })
}
// ============================================================================
//                           FUNCTION: heartBeat
// ============================================================================
/**
 * Provides a heartbeat message to inform other extensions of our status
 */
function heartBeatCallback ()
{
    let connected = false;
    let color = "red";

    if (serverConfig.multistreamEnabled === "on")
    {
        connected = true;
        if (ffmpeg.ffmpegBusyFlags().streaming)
            color = "green"
        else
            color = "orange"
    }
    else
        color = "red"
    sr_api.sendMessage(localConfig.DataCenterSocket,
        sr_api.ServerPacket("ChannelData",
            serverConfig.extensionname,
            sr_api.ExtensionPacket(
                "HeartBeat",
                serverConfig.extensionname,
                {
                    connected: connected,
                    color: color
                },
                serverConfig.channel),
            serverConfig.channel
        ),
    );
    localConfig.heartBeatHandle = setTimeout(heartBeatCallback, localConfig.heartBeatTimeout)
}
// ============================================================================
//                                  EXPORTS
// Note that initialise is mandatory to allow the server to start this extension
// ============================================================================
export { initialise, triggersandactions };