// 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 * as fs from 'fs';
import * as path from 'path';
import { createTracksPlayer } from './tracks_jplayer.js';
import { createVlcPlayer } from './vlc_player.js';
import { audioStreamHttpCallable,
         audioStreamPageLoad,
         deviceStreamHttpCallable,
         deviceStreamPageLoad,
         fetchAudioStream,
         fetchDeviceStream,
         initDeviceAudioStream,
         initTracksAudioStream } from './audiostream.js';
import { setupRecordings, recordingFileName } from './recordings.js';
import { processOptions } from './parse_arguments.js';
import { history } from './history.js';
import { log } from './log.js';

// Convenience method that finds e in a and returns element that is off
// positions forward from it from it.
const arrayOff = (a, e, off) => a[(a.indexOf(e) + off % a.length + a.length) % a.length];

export const setupSources = (opts) => {
    const hista = [];           // array of histories for sources
    const sources = (() => {    // array of sources, contents of sources.json
        try {
            return JSON.parse(fs.readFileSync(path.join(opts.etc, 'sources.json')));
        } catch (err) {
            log.error("Error reading sources.json: "+err);
            process.exit(78);
        }
    })();

    // Map from group name to array of sources in the corresponding group.
    const groups = { };
    for (let i = 0; i < sources.length; i++) {
        // Generate a group name if there is none.
        const groupName = sources[i].group || (sources[i].group = "##"+i);

        // Add source index to the right group, creating group if it doesn't exist.
        (groups[groupName] || (groups[groupName] = [])).push(i);
    }

    // Memory to use for unspecified server playing. Stream sources don’t set this.
    let lastChosenSourceIx = 0;
    let lastChosenSourceIxInGroup = { };
    const setLastChosen = (sourceIx) => {
        lastChosenSourceIx = sourceIx;
        lastChosenSourceIxInGroup[sources[sourceIx].group] = sourceIx;
    }

    const recordings = setupRecordings(opts);

    const playerBrowse = (query, resp, player) => resp.passProcessedTextFile('html', 'browse.html', {
        ...query,
        pathprefix: query.path ? query.path+path.sep : "",
        background_spec: player.endsWith('stream') ? opts.streambackground : opts.serverbackground
    });

    // Each element has:
    // - a launch(sourceIx, item) that returns a promise that resolves to an
    //   address to redirect the client to. It throws an exception if unable to
    //   launch; and
    // - an httpCallable(query) function that returns a promise to produce the
    //   correct httpCallable interface for the source.
    const players = {
        kliptrack: {
            browse: playerBrowse,
            httpCallable: (query) => requestHttpCallable(),
            launch: (sourceIx, item) => {
                setLastChosen(sourceIx);
                resetHttpCallablePromise();         // make next access wait for it
                const hist = hista[sourceIx] || (hista[sourceIx] = history(path.join(opts.hist, "history-track-"+sourceIx)));
                let histIx;
                if (!item) {                        // nothing specified, play latest in history
                    histIx = hist.length-1;
                    item = hist.getItem(histIx);
                } else if (item.hist_entry !== undefined) { // position in history specified
                    histIx = Math.max(0, Math.min(hist.length-1, item.hist_entry));
                    item = hist.getItem(histIx);
                } else {                            // item determines what to play, find it in history
                    histIx = hist.putItem(item);
                }
                if (!item) {                        // if item not set at this point, history is at fault
                    log.error("No history item for "+sourceIx+": "+histIx);
                    throw "Failed kliptrack history reference";
                }

                // Set up player to launch, augmenting its intf when ready.
                if (!item.hasOwnProperty('path')) { item.path = '' }
                return launchPlayer(() => createTracksPlayer({
                    ...opts,
                    ...processOptions(sources[sourceIx].options || { })
                }, opts.joutput, sources[sourceIx].path, item).then(intf => {
                    intf.onExit(statusOrMessage => {
                        if (statusOrMessage === 0) { // exited because run to end
                            delete item.track_number;
                            delete item.track_time;
                        }
                        hist.save();
                    });

                    // Set some remote-callable functions to act on source history.
                    intf.httpCallable.channel_up = () => { if (histIx > 0) { choosePlayer(sourceIx, { hist_entry: histIx-1 }) } }
                    intf.httpCallable.channel_down = () => { if (histIx < hist.length-1) { choosePlayer(sourceIx, { hist_entry: histIx+1 }) } }
                    intf.httpCallable.clear = () => {
                        if (histIx < 1) { return }  // already cleared
                        hist.clear(histIx);
                        const target = Math.min(histIx, hist.length-1);
                        histIx = -1;
                        choosePlayer(sourceIx, { hist_entry: target });
                    }
                    intf.httpCallable.program = () => {
                        if (histIx < 1) { return }  // cleared
                        histIx = hist.moveToEnd(histIx);
                    }
                    return intf;
                })).then(() => "/server_controls");
            }
        },
        audiostream: {
            browse: playerBrowse,
            pageLoad: (query) => audioStreamPageLoad(query),
            fetchStream: (query, resp) => fetchAudioStream (query, resp),
            httpCallable: (query) => audioStreamHttpCallable(query),
            launch: (sourceIx, item, host) => initTracksAudioStream(opts, sources[sourceIx], item, host)
                .then(id => "/stream_controls?source="+sourceIx+"&player_id="+id)
        },
        devicestream: {
            pageLoad: (query) => initDeviceAudioStream(opts, query.source, sources[query.source]).then(() => deviceStreamPageLoad(query)),
            fetchStream: (query, resp) => fetchDeviceStream(query, resp),
            httpCallable: (query) => deviceStreamHttpCallable(query),
            launch: (sourceIx, item, host) => initDeviceAudioStream(opts, sourceIx, sources[sourceIx])
                .then(id => "/stream_controls?source="+sourceIx+"&player_id="+id)
        },
        vlc: {
            httpCallable: (query) => requestHttpCallable(),
            launch: (sourceIx, item) => {
                setLastChosen(sourceIx);
                resetHttpCallablePromise();         // make next access wait for it
                const sourceRec = sources[sourceIx];
                return launchPlayer(() => {
                    const intf = createVlcPlayer(sourceRec.group+"/"+sourceRec.label, opts.vlc, sourceRec.url, opts.vlcargs);
                    if (sourceRec.group === 'radio') {
                        intf.httpCallable.channel_up =   () => { choosePlayer(arrayOff(groups.radio, sourceIx, +1)) }
                        intf.httpCallable.channel_down = () => { choosePlayer(arrayOff(groups.radio, sourceIx, -1)) }
                    }
                    return intf;
                }).then(() => "/server_controls");
            }
        },
        recordings: {
            depth: 1,
            browse: recordings.browse,
            pageLoad: (query) => audioStreamPageLoad(query),
            fetchStream: (query, resp) => fetchAudioStream(query, resp),
            httpCallable: (query) => audioStreamHttpCallable(query),
            launch: (sourceIx, item, host) => recordingFileName(item.path, opts)
                .then(fnam => initTracksAudioStream(opts, { ...sources[sourceIx], path: opts.recdir }, { ...item, path: fnam }, host))
                .then(id => "/stream_controls?source="+sourceIx+"&player_id="+id)
        }
    }

    // Value for interface function httpCallable.
    let httpCallablePromise, provideHttpCallable, cancelHttpCallable, rejectHttpCallable;
    const resetHttpCallablePromise = () => {
        cancelHttpCallable = () => { httpCallablePromise = Promise.reject() }
        httpCallablePromise = new Promise((resolve, reject) => {
            provideHttpCallable = resolve;
            rejectHttpCallable = reject;
        }).then(httpCallable => {
            httpCallable.shuffle = httpCallable.shuffle || (() => offPlay(1));
            return httpCallable;
        });
    }
    resetHttpCallablePromise();
    const requestHttpCallable = () => {
        cancelHttpCallable = rejectHttpCallable;
        return httpCallablePromise;
    }

    // Simplifying wrapper to choose from players.
    const choosePlayer = (sourceIx, item, host) => players[sources[sourceIx].player].launch(sourceIx, item, host);

    // To be called for http request.
    const performChoice = (query, resp, host) => {
        if (isNaN(query && query.source)) {
            resp.writeHead(303, { "Location": httpCallable.rootPlayer ? "/choice?source=0" : "/server_controls" });
            resp.end();
            return;
        }
        const sourceRec = sources[query.source];
        const player = players[sourceRec.player];
        if ((query.path ? query.path.split(path.sep).length : 0) < (player.depth || sourceRec.depth)) {
            player.browse(query, resp, sourceRec.player)
        } else {
            if (!sourceRec) {
                log.error("Not a valid source id: "+query.source);
                resp.httpError();
            } else {
                choosePlayer(query.source, query, host).then(redir => {
                    resp.writeHead(303, { "Location": redir });
                    resp.end();
                }).catch(err => { resp.httpError("Unable to launch: "+(err.message || err)) });
            }
        }
    }

    const defaultPlay = () => { choosePlayer(lastChosenSourceIx) }

    // Moves between groups, but skips group if device not available.
    const offPlay = (off, loopi) => {
        if (loopi >= groups.length) { return } // give up if all tried
        const group = arrayOff(Object.keys(groups), sources[lastChosenSourceIx].group, off);
        const sourceIx = lastChosenSourceIxInGroup[group] || groups[group][0];
        if (sources[sourceIx].device) { // check if device is available
            fs.promises.open(sources[sourceIx].device, fs.O_RDONLY | fs.O_NONBLOCK)
                .then(fh => { fh.close(); choosePlayer(sourceIx) })
                .catch(() => { offPlay(off+Math.sign(off), (loopi || 0)+1) });
        } else {
            choosePlayer(sourceIx);
        }
    }

    // Creates a dummy player that is active when nothing is playing.
    const rootCreate = () => {
        let provideFinish;
        const finishPromise = new Promise(resolve => { provideFinish = resolve });
        const intf = {
            ready: () => Promise.resolve(intf),
            httpCallable: {
                rootPlayer: true,
                play: defaultPlay,
                toggle_pause: defaultPlay,
                quit: () => {
                    provideFinish();
                    return finishPromise;
                }
            },
            finish: () => finishPromise,
        }
        return intf;
    }

    let toLaunch = rootCreate;
    let httpCallable;            // interface of current player, set in launchLoop

    // Infinite recursion loop that creates a player from toLaunch, starts it,
    // waits for it to quit, and repeats.
    const launchLoop = () => {
        log.debug("Launching player");
        const create = toLaunch;
        toLaunch = rootCreate;
        Promise.resolve()
            .then(() => create())
            .then(intf => intf.ready())
            .then(intf => {
                provideHttpCallable(httpCallable = intf.httpCallable);
                return intf.finish();
            })
            .then(() => {
                log.debug("Player finished");
                resetHttpCallablePromise();
                launchLoop();
            })
            .catch(ans => {
                log.debug("Player launch error: "+ans.message);
                launchLoop();
            });
    }


    // Sets the creation funtion for the next iteration of launchLoop.
    const launchPlayer = (create) => {
        toLaunch = create;
        return httpCallable.quit();
    }

    launchLoop();                   // start the loop

    // Allow player to quit normally (saving state) on termination signal.
    process.on('SIGTERM', () => {
        launchPlayer(() => {
            log.info("Klipspringer killing its own process to terminate");

            // It's crazy, but process.exit() does not work because of the open input
            // stream for the remote device, so this is needed to terminate:
            process.kill(process.pid, 'SIGKILL');
        });
    });

    return new Proxy({
        performChoice: performChoice,
        httpCallable: (query) => !query || query.source === undefined ?
            requestHttpCallable() :
            players[sources[query.source].player].httpCallable(query),
        recordingIntf: () => recordings.intf,
        defaultPlay: defaultPlay,
        lastChosenSourceIx: () => lastChosenSourceIx,
        nextPlay: () => offPlay(1),
        prevPlay: () => offPlay(-11),
        sourcesHtml: sources.map((s, i) => "<a href='/choice?source="+i+"'>"+s.label+"</a><br>\n").join(''),
        radioChannels: groups.radio ? groups.radio.length : 0,
        radioPlay: (i) => { if (groups.radio) { choosePlayer(groups.radio[i % groups.radio.length]) } },
        pageLoad: (query) => players[sources[query.source].player].pageLoad(query),
        fetchStream: (query, resp) => players[sources[query.source].player].fetchStream(query, resp)
    }, { get: (target, prop) => target[prop] || sources[prop] });
}

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