// 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 { createServer } from 'net';
import * as net from 'net';
import * as path from 'path';
import { createTracksPlayer } from './tracks_jplayer.js';
import { createDevicePlayer } from './device_jplayer.js';
import { log } from './log.js';
import { clientButtons, deviceButtons } from './svg_images.js';

const intfs = { }, hostRunningId = { }, deviceStreamIntf = { };
const EMPTY = Buffer.alloc(0);

const getDeviceStreamIntf = (sourceIx) => deviceStreamIntf[sourceIx] || Promise.reject("Device source "+sourceIx+" not launched");
const getIntf = (id) => {
    const intf = intfs[id];
    if (!intf) { throw "Stream expired" }
    return intf;
}

export const audioStreamHttpCallable = (query) => getIntf(query.player_id).then(intf => intf.ready()).then(intf => intf.httpCallable);
export const deviceStreamHttpCallable = (query) => getDeviceStreamIntf(query.source).then(intf => intf.ready()).then(intf => intf.httpCallable);
export const fetchAudioStream = (query, resp) => getIntf(query.player_id).then(intf => intf.fetchStream(query, resp));
export const fetchDeviceStream = (query, resp) => getDeviceStreamIntf(query.source).then(intf => intf.fetchStream(query, resp));
export const audioStreamPageLoad = (query) => getIntf(query.player_id).then(intf => intf.ready()).then(intf => intf.pageLoad());
export const deviceStreamPageLoad = (query) => getDeviceStreamIntf(query.source).then(intf => intf.pageLoad());

// Sets the running intf for the host.
const setRunning = async (host, id) => {
    const runningId = hostRunningId[host];
    if (runningId && runningId !== id) {
        const intf = await intfs[runningId];
        if (intf) { await intf.httpCallable.quit().catch(() => { }) }
    }
    hostRunningId[host] = id;
}

const mimeType = (type) => {
    switch (type) {
    case 'oga':
        return 'audio/ogg';
    case 'flac':
        return  'audio/flac';
    case 'au':
        return 'audio/basic';
    case 'ogg':
        return 'audio/ogg';
    case 'mp3':
        return 'audio/mpeg';
    default:
        throw "unknown stream audio type: "+type
    }
}

const fetchStream = (intf, controlPath, query, resp) => intf.ready().then(() => {
    // Some browsers (Safari) refuse to play FLAC if a Content-Type header is
    // included, so I simply commented this line out to make it work:
    //
    // resp.writeHead(200, { 'Content-Type': mimeType(query.type) });

    if (!resp.expectsBody()) { resp.end(); return }
    const sock = net.connect(controlPath, () => {
        sock.write(Object.entries({
            ...query,
            cmd: 'fetchstream',
        }).map(([p, v]) => p+'='+encodeURIComponent(v.toString())).join('&')+'\n');
        intf.start_track = +query.track + intf.tracklist_off;
        intf.start_time = +query.time;
        intf.played_time = 0;
    });
    sock.on('data', chunk => { resp.write(chunk, 'binary') })
        .on('end', () => { if (!resp.writableEnded) { resp.end(EMPTY, 'binary') } })
        .on('close', () => { if (!resp.writableEnded) { resp.end(EMPTY, 'binary') } })
        .on('error', err => { if (!resp.writableEnded) { log.error(err); resp.end(EMPTY, 'binary') } });
});

export const initTracksAudioStream = (opts, source, item, host, loadCount) => {
    const id = item.id || (item.id = Math.random().toString(36).slice(2, 8));
    const controlPath = path.join(opts.sockdir, 'control.'+id+'.sock');

    setRunning(host, id);

    loadCount = loadCount || 0; // number of times controls page is loaded

    const intfPromise = Promise.resolve(createTracksPlayer(opts, 'none', source.path, item)).then(intf => {
        const superQuit = intf.httpCallable.quit;
        intf.httpCallable.quit = () => superQuit().then(ans => {
            delete intfs[id];
            return ans;
        });

        intf.pageLoad = () => {
            const loadId = loadCount++; // if > 0, this is a reload
            return (loadId === 0 ? Promise.resolve() : superQuit().then(() => {
                initTracksAudioStream(opts, source, item, host, loadCount);
            })).then(() => ({
                load_id: loadId,
                stream_track: intf.start_track-intf.tracklist_off || 0,
                stream_time: (intf.start_time || 0)+(intf.played_time || 0),
                buttons: clientButtons,
                timeout: 30,
                source: JSON.stringify(source)
            }));
        }

        intf.fetchStream = (query, resp) => fetchStream(intf, controlPath, query, resp);

        intf.httpCallable.set_played_get_status = (qstring, query) => {
            intf.played_time = +query.time;
            return intf.statusCommand(qstring, query);
        }

        // Trap the commands that may restart the stream.
        for (const cmd of ['next', 'prev', 'seeka', 'seekr']) {
            const supcmd = intf.httpCallable[cmd];
            if (supcmd) {
                intf.httpCallable[cmd] = (qstring, query) => {
                    return supcmd(qstring, query).then(ans => {
                        if (ans.restart && !query.dry) {
                            intf.start_track = +ans.track;
                            intf.start_time = +ans.time;
                            intf.played_time = 0;
                        }
                        return ans;
                    });
                }
            }
        }

        return intf;
    });
    intfs[id] = intfPromise;
    return intfPromise.then(() => id);
}

export const initDeviceAudioStream = (opts, sourceIx, source) => Promise.resolve(deviceStreamIntf[sourceIx])
    .then(intf => {
        let loadCount = 0;

        if (intf) {                                             // a process is already running
            return Math.random().toString(36).slice(2, 8);      // give new stream its own id
        }

        const controlPath = path.join(opts.sockdir, 'control.source-'+sourceIx+'.sock');
        intf = createDevicePlayer(opts, controlPath, source.device, source.defaults);
        deviceStreamIntf[sourceIx] = Promise.resolve(intf);
        intf.onExit(() => { delete deviceStreamIntf[sourceIx] });

        // Override the command used to quit the player.
        intf.niceQuit = () => {
            delete deviceStreamIntf[sourceIx];
            intf.command('cmd=quit').catch(() => { log.debug("Nice quit of device stream failed") });
        }

        intf.pageLoad = () => ({
            stream_track: 0,
            stream_time: 0,
            buttons: deviceButtons,
            timeout: 0,
            load_id: loadCount++,
            source: JSON.stringify(source)
        });

        intf.fetchStream = (query, resp) => fetchStream(intf, controlPath, query, resp);
        intf.httpCallable.set_played_get_status = (qstring, query) => intf.command(qstring, query);
        return intf.id;
    });


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