// 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