Source: websocket.js

"use strict";
// SPDX-License-Identifier: GPL-3.0-or-later
// myMPD (c) 2018-2024 Juergen Mang <mail@jcgames.de>
// https://github.com/jcorporation/mympd

/** @module websocket_js */

/**
 * Checks if the websocket is connected
 * @returns {boolean} true if websocket is connected, else false
 */
function getWebsocketState() {
    if (socket !== null &&
        socket.readyState === WebSocket.OPEN)
    {
        return true;
    }
    return false;
}

/**
 * Connects to the websocket, registers the event handlers and enables the keepalive timer
 * @returns {void}
 */
function webSocketConnect() {
    if (websocketKeepAliveTimer === null) {
        websocketKeepAliveTimer = setInterval(websocketKeepAlive, 5000);
    }

    if (getWebsocketState() === true) {
        logDebug('Socket already connected');
        return;
    }
    else if (socket !== null &&
        socket.readyState === WebSocket.CONNECTING)
    {
        logDebug('Socket connection in progress');
        return;
    }

    const wsUrl = getMyMPDuri('ws') + '/ws/' + localSettings.partition;
    logDebug('Connecting to ' + wsUrl);

    try {
        socket = new WebSocket(wsUrl);
    }
    catch(error) {
        logError(error);
    }

    socket.onopen = function() {
        logDebug('Websocket is connected');
        socket.send('id:' + jsonrpcClientId);
        websocketLastPong = getTimestamp();
    };

    socket.onmessage = function(msg) {
        logDebug('Websocket message: ' + msg.data);
        if (msg.data === 'pong' ||
            msg.data === 'ok') {
            // websocket keepalive or jsonrpc id registration
            websocketLastPong = getTimestamp();
            return;
        }
        if (msg.data.length > 100000) {
            logError("Websocket message is too large, discarding");
            return;
        }
        let obj;
        try {
            obj = JSON.parse(msg.data);
        }
        catch(error) {
            logError('Invalid websocket notification received: ' + msg.data);
            logError(error);
            return;
        }

        // async response
        if (obj.result ||
            obj.error)
        {
            const callback = obj.result
                ? getResponseCallback(obj.result.method)
                : getResponseCallback(obj.error.method);
            checkAPIresponse(obj, callback, true);
            return;
        }

        // notification
        switch (obj.method) {
            case 'welcome':
                showNotification(tn('Connected to myMPD') + ': ' +
                    tn('Partition') + ' ' + localSettings.partition, 'general', 'info');
                getState();
                if (session.token !== '') {
                    validateSession();
                }
                toggleUI();
                break;
            case 'update_queue':
            case 'update_state':
                //rename param to result
                obj.result = obj.params;
                delete obj.params;
                if (app.id === 'QueueCurrent' &&
                    obj.method === 'update_queue')
                {
                    execSearchExpression(elGetById('QueueCurrentSearchStr').value);
                }
                parseState(obj);
                break;
            case 'mpd_disconnected':
                if (progressTimer) {
                    clearTimeout(progressTimer);
                }
                settings.partition.mpdConnected = false;
                toggleUI();
                break;
            case 'mpd_connected':
                //MPD connection established get state and settings
                showNotification(tn('Connected to MPD'), 'general', 'info');
                getState();
                getSettings(parseSettings);
                break;
            case 'update_options':
                getSettings(parseSettings);
                break;
            case 'update_outputs':
                sendAPI('MYMPD_API_PLAYER_OUTPUT_LIST', {}, parseOutputs, false);
                break;
            case 'update_started':
                showNotification(tn('Database update started'), 'database', 'info');
                toggleAlert('alertUpdateDBState', true, tn('Updating MPD database'));
                break;
            case 'update_database':
            case 'update_finished':
                updateDBfinished(obj.method);
                toggleAlert('alertUpdateDBState', false, '');
                break;
            case 'update_volume':
                //rename param to result
                obj.result = obj.params;
                delete obj.params;
                parseVolume(obj);
                break;
            case 'update_stored_playlist':
                if (app.id === 'BrowsePlaylistList') {
                    sendAPI('MYMPD_API_PLAYLIST_LIST', {
                        "offset": app.current.offset,
                        "limit": app.current.limit,
                        "searchstr": app.current.search,
                        "type": 0,
                        "sort": app.current.sort.tag,
                        "sortdesc": app.current.sort.desc,
                        "fields": settings.viewBrowsePlaylistList.fields
                    }, parsePlaylistList, false);
                }
                else if (app.id === 'BrowsePlaylistDetail') {
                    sendAPI('MYMPD_API_PLAYLIST_CONTENT_LIST', {
                        "offset": app.current.offset,
                        "limit": app.current.limit,
                        "expression": app.current.search,
                        "plist": app.current.tag,
                        "fields": settings.viewBrowsePlaylistDetailFetch.fields
                    }, parsePlaylistDetail, false);
                }
                break;
            case 'update_last_played':
                if (app.id === 'QueueLastPlayed') {
                    sendAPI('MYMPD_API_LAST_PLAYED_LIST', {
                        "offset": app.current.offset,
                        "limit": app.current.limit,
                        "fields": settings.viewQueueLastPlayedFetch.fields,
                        "expression": app.current.search
                    }, parseLastPlayed, false);
                }
                break;
            case 'update_home':
                if (app.id === 'Home') {
                    sendAPI("MYMPD_API_HOME_ICON_LIST", {}, parseHomeIcons, false);
                }
                break;
            case 'update_jukebox':
                if (app.id === 'QueueJukeboxSong' ||
                    app.id === 'QueueJukeboxAlbum')
                {
                    getJukeboxList(app.id);
                }
                break;
            case 'update_cache_started':
                showNotification(tn('Cache update started'), 'database', 'info');
                toggleAlert('alertUpdateCacheState', true, tn('Updating caches'));
                break;
            case 'update_cache_finished':
                if (app.id === 'BrowseDatabaseAlbumList') {
                    sendAPI("MYMPD_API_DATABASE_ALBUM_LIST", {
                        "offset": app.current.offset,
                        "limit": app.current.limit,
                        "expression": app.current.search,
                        "sort": app.current.sort.tag,
                        "sortdesc": app.current.sort.desc,
                        "fields": settings.viewBrowseDatabaseAlbumListFetch.fields
                    }, parseDatabaseAlbumList, true);
                }
                toggleAlert('alertUpdateCacheState', false, '');
                break;
            case 'notify':
                showNotification(tn(obj.params.message, obj.params.data), obj.params.facility, obj.params.severity);
                break;
            case 'script_dialog': 
                showScriptDialog(obj.params);
                break;
            default:
                logDebug('Unknown websocket notification: ' + obj.method);
                break;
        }
    };

    socket.onclose = function(event) {
        logError('Websocket connection closed: ' + event.code);
        if (appInited === true) {
            toggleUI();
            if (progressTimer) {
                clearTimeout(progressTimer);
            }
        }
        else {
            showAppInitAlert(tn('myMPD connection closed'));
        }
        socket = null;
    };

    socket.onerror = function() {
        logError('Websocket error occurred');
        if (socket !== null) {
            try {
                socket.close();
            }
            catch(error) {
                logError(error);
            }
        }
    };
}

/**
 * Closes the websocket and terminates the keepalive timer
 * @returns {void}
 */
function webSocketClose() {
    if (websocketKeepAliveTimer !== null) {
        clearInterval(websocketKeepAliveTimer);
        websocketKeepAliveTimer = null;
    }
    if (socket !== null) {
        //disable onclose handler first
        try {
            socket.onclose = function() {};
            socket.close();
        }
        catch(error) {
            logError(error);
        }
    }
    socket = null;
}

/**
 * Sends a ping keepalive message to the websocket endpoint
 * or reconnects the socket if the socket is disconnected or stale.
 * Refreshes the home widgets if the socket is connected.
 * @returns {void}
 */
function websocketKeepAlive() {
    const awaitedTime = getTimestamp() - 7;
    if (websocketLastPong <  awaitedTime) {
        // stale websocket connection
        logError('Stale websocket connection, reconnecting');
        toggleAlert('alertMympdState', true, tn('myMPD connection failed, trying to reconnect'));
        webSocketClose();
        webSocketConnect();
    }
    else if (getWebsocketState() === true) {
        // websocket is connected
        try {
            socket.send('ping');
        }
        catch(error) {
            toggleAlert('alertMympdState', true, tn('myMPD connection failed, trying to reconnect'));
            logError(error);
            webSocketClose();
            webSocketConnect();
            return;
        }
        // Check if home widgets should be refreshed
        if (widgetRefresh.length > 0 &&
            app.current.card === 'Home')
        {
            homeWidgetsRefresh();
        }
    }
    else {
        // websocket is not connected
        logDebug('Reconnecting websocket');
        toggleAlert('alertMympdState', true, tn('myMPD connection failed, trying to reconnect'));
        webSocketClose();
        webSocketConnect();
    }
}