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