// Copyright 2014-2025 Jesper Larsson
//
// This file is part of Klipspringer, <https://klipspringer.avadeaux.net/>
//
// Klipspringer is free software: you can redistribute it and/or modify it under the terms of the
// GNU General Public License as published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// Klipspringer 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
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with Klipspringer. If
// not, see <https://www.gnu.org/licenses/>.

import { spawn } from 'child_process';
import { log } from './log.js';

export const createPlayerCommon = (
    name,         // name of player, for messages
    cmd,          // program to run as process
    options,      // arguments to cmd
    env,          // optional, environment for process
    id,           // optional id string, generated if not provided
    filesP        // optional, [promise that resolves to] array of additional arguments
) => {
    id = id || Math.random().toString(36).slice(2, 8);

    let proc = null;
    let stdoutBuffered = '';
    let paused = false, pauseTarget = false, readySet = false;

    let killTimeout = null;

    // Pause interface with a bit of intelligence to prevent erratic behavior.
    const callTogglePauseTo = (intf, target) => {
        if (! intf.togglePauseTo) { return }   // none to call
        if (target === pauseTarget) { return } // has or is going to the desired state
        if (paused !== pauseTarget) {          // state change underway, retry soon
            return new Promise((resolve, reject) => {
                setTimeout(() => { resolve(callTogglePauseTo(intf, target)) }, 100)
            });
        }
        pauseTarget = target;
        return intf.togglePauseTo(target).then(() => {
            paused = target;
        }).catch(() => { });
    }

    const exitWithMessage = (message) => {
        runExitCallbacks(message).then(() => {
            cancelReady({ message: message, defunct: true });
            provideFinish({ message: message });
        });
    }

    const onExitCallbacks = [];
    const runExitCallbacks = async (statusOrMessage) => {
        for (const f of onExitCallbacks) { f(statusOrMessage) }
    }

    const exitWithStatus = (status) => {
        runExitCallbacks(status).then(() => {
            cancelReady({ status: status, message: "Exited with status "+status, defunct: true });
            provideFinish({ status: status, message: "Exited with status "+status });
        });
    }

    const intf = {
        name: name,
        id: id,

        // --------------------------------------------------------------------
        // TO BE OVERRIDDEN FOR SPECIALIZED BEHAVIOR

        // String or regexp to find in process output before setting to ready.
        readyPattern: "",

        // Processes part of bufferedString, returns the number of chars
        // gobbled. If there isn't a complete thing to process, return 0. By
        // default, if ready is not yet set, checks for readyPattern, and if
        // found sets ready and returns the end position of the pattern,
        // otherwise sends the string to the process standarrd output and
        // returns the length.
        processStdout: (bufferedString) => {
            process.stdout.write(bufferedString);
            return bufferedString.length;
        },

        // Handle output to stderr.
        noteStderr: (buf) => {
            log.error(buf.toString());
        },

        // Nicely ask the process to quit. May return promise that resolves
        // when the request has been successfully sent and rejects if it could
        // not be sent.
        niceQuit: () => {
            intf.writeProcStdin('quit\n').catch(() => { log.debug("Nice quit failed") });
        },

        // Set pause to target status (true or false), which is always supposed
        // to be different than the current status. May return promise that
        // resolves when the request has been successfully sent and rejects if
        // it could not be sent.
        togglePauseTo: undefined,

        // Add callback to be invoked when underlying process exits. Callback
        // gets either exit status (number) or message as argument.
        onExit: (f) => onExitCallbacks.push(f),

        // END TO BE OVERRIDDEN FOR SPECIALIZED BEHAVIOR.
        // --------------------------------------------------------------------

        ready: () => {
            cancelReady = rejectReady;
            return readyPromise;
        },

        finish: () => finishPromise,

        writeProcStdin: (s) => {
            return new Promise((resolve, reject) => {
                proc.stdin.write(s, err => {
                    if (err) { reject(err) }
                    else     { resolve() }
                });
            });
        },

        pausedStatus: () => pauseTarget,

        // Subset of interface that can be accessed via http.
        httpCallable: {
            // Tells the process to quit with escalating determination, starting
            // with call to niceQuit. Does not wait for the process to exit (the
            // start promise resolves when the process exits).
            quit: () => {
                if (proc) {
                    intf.niceQuit(proc);
                    killTimeout = setTimeout(
                        () => {
                            if (proc) {
                                proc.kill('SIGTERM');
                                killTimeout = setTimeout(() => { if (proc) { proc.kill('SIGKILL') } }, 5000);
                            }
                        },
                        3000);
                }
                return intf.finish();
            },

            pause: () => callTogglePauseTo(intf, true),
            play: () => callTogglePauseTo(intf, false),
            toggle_pause: () => callTogglePauseTo(intf, !pauseTarget),
        }
    };

    let cancelReady = () => { intf.ready = () => Promise.reject() }
    let rejectReady;
    const readyPromise = new Promise((resolve, reject) => {
        intf.setReady = () => {
            readySet = true;
            resolve(intf);
        }
        rejectReady = reject;
    });

    let provideFinish;
    const finishPromise = new Promise(resolve => { provideFinish = resolve });

    // Start the process.
    Promise.resolve(filesP || []).then(files => {
        const args = options.concat(files);
        log.debug(cmd+" "+args.join(" "));
        proc = spawn(cmd, args, env);

        proc.stdin.on('error', err => { exitWithMessage(err ? err.toString() : "stdin error") });
        proc.stderr.on('error', err => { exitWithMessage(err ? err.toString() : "stderr error") });

        proc.stdout.on('data', buf => {
            stdoutBuffered += buf.toString();
            if (!readySet) {
                const p = intf.readyPattern;
                if (typeof p === 'string') {
                    const m = stdoutBuffered.indexOf(p);
                    if (m >= 0) {
                        intf.setReady();
                        stdoutBuffered = stdoutBuffered.slice(m + p.length);;
                    }
                } else {
                    const m = p.exec(stdoutBuffered);
                    if (m) {
                        intf.setReady();
                        stdoutBuffered = stdoutBuffered.slice(m.index + m[0].length);
                    }
                }
            }
            while (true) {
                const endProcessed = intf.processStdout(stdoutBuffered);
                if (endProcessed === 0) { break; }
                stdoutBuffered = stdoutBuffered.slice(endProcessed);
            }
        });
        proc.stderr.on('data', buf => {
            intf.noteStderr(buf);
        });
        proc.on('exit', (status, signal) => {
            log.debug("exit "+intf.name+": "+status+"/"+signal);
            proc = null;
            clearTimeout(killTimeout);
            if (status !== null) { exitWithStatus(status) }
            else { exitWithMessage("Terminated by signal "+signal) }
        });
    }).catch(err => {
        log.debug(err+"\n"+(err.stack || "(No stack)"));
        exitWithMessage(err && err.toString());
    });

    return intf;
}

Version: v4.3.2.2 (2026-05-16T17:03:34+02:00)
Raw file
Source code overview
Klipspringer home