// 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 net from 'net';
import * as path from 'path';
import { readFileSync, existsSync, statSync } from 'fs';
import { readdir } from 'fs/promises';
import { createPlayerCommon } from './player_common.js';
import { decodeFormat } from './recordings.js';
import { log } from './log.js';
export const createTracksPlayer = (opts, device, musicDir, item) => {
const id = item.id || (item.id = Math.random().toString(36).slice(2, 8));
const jplayer = 'net.avadeaux.klipspringer.TrackPlayer';
const controlPath = path.join(opts.sockdir, 'control.'+id+'.sock');
const optargs = [...opts.java_opts];
optargs.push('-Xmx200m');
optargs.push(jplayer);
optargs.push('-controlsock', path.resolve(controlPath));
optargs.push('-cmdintf', 'false');
if (device) { optargs.push('-output', device) }
optargs.push(...opts.jtrackopts);
let filesP, artist, album, tracks;
const musicPath = item.path ? path.join(musicDir, item.path) : musicDir;
if (existsSync(musicPath) && statSync(musicPath).isDirectory()) {
filesP = readdir(musicPath, { withFileTypes: true }).then(files => {
// Read file names and do the processing that depends on it before
// returning them, possibly adding a -seek argument.
// Get rid of directories etc., and replace underscores with spaces.
const re = new RegExp("^"+opts.trackRE+"$");
files = files.filter(e => e.isFile() || e.isSymbolicLink()) // lose directories
.map(e => e.name);
const troff = files[0] ? +(files[0].match(re) || [, 1])[1] : 0; // number of first track
intf.tracklist_off = troff;
tracks = files.reduce((ok, e, i) => ok && +(e.match(re) || [])[1] === i+troff, true)
? files.map(e => e.match(re)[2])
: files;
tracks = tracks.map(e => e.replace(/_/g, ' ')); // replace _ with space
album = !item.path ? null : item.path.slice(item.path.lastIndexOf(path.sep)+1).replace(/_/g, ' ');
artist = album && album.length < item.path.length ?
item.path.slice(0, item.path.length - album.length - 1).replace(/_/g, ' ')
: null;
if (item.path) { files = files.map(f => path.join(item.path, f)) } // prepend agent/album/
files = ['-trackoff', troff.toString()].concat(files);
if (item.track_number === undefined) {
intf.start_track = Math.max(1, troff);
if (intf.start_track != 1 || troff != 1) { files = ['-starttrack', intf.start_track.toString()].concat(files) }
} else { // item specifies start track
intf.start_track = +item.track_number;
files = ['-starttrack', item.track_number.toString()].concat(files);
}
if (item.track_time === undefined) {
intf.start_time = 0;
} else {
intf.start_time = +item.track_time;
files = ['-seek', item.track_time.toString()].concat(files);
}
return files;
});
const mixp = path.join(musicPath, 'data', 'mix.json');
const mixs = existsSync(mixp) && readFileSync(mixp);
if (mixs) {
optargs.push('-mixjson');
optargs.push(mixs);
}
} else {
filesP = [item.path];
const fmt = decodeFormat(item.path);
if (fmt) {
tracks = [fmt.date];
} else {
const film = item.path.match(/^(.+)(?:\.[a-zA-Z][a-zA-Z0-9]*)$/);
tracks = [ (film ? film[1] : item.path).replace(/_/g, ' ') ];
}
}
// Get superintf by createPlayerCommon.
const intf = createPlayerCommon("Tracks player", opts.java, optargs, { cwd: musicDir }, id, filesP)
// Set image file names, if present.
let images;
readdir(path.join(musicPath, 'data')).then(files => {
images = {
source: item.source,
files: files.filter(dfnam => /\.(jpeg|jpg|gif|png|svg)$/i.test(dfnam))
}
}).catch(() => { }); // no images, ignore
// Skips during pause need special treatment.
let pausedSkip = false;
intf.togglePauseTo = (target) => {
return intf.command('cmd=pause&state='+target).then(() => {
if (!target) { pausedSkip = false }
});
}
// Override the command used to quit the player.
intf.niceQuit = () => {
intf.command('cmd=quit').then(status => {
item.track_number = status.track_number;
item.track_time = status.track_time;
}).catch(() => { log.debug("Nice quit failed") });
}
// Override pattern to check stdout for before ready.
intf.readyPattern = /^Ready control socket.*\n?/m;
// Sends command to player, returning a promise that resolves with answer
// object when it is done.
intf.command = (qstring) => intf.ready().then(() => new Promise((resolve, reject) => {
let ansString = '';
const sock = net.connect(controlPath, () => { sock.write(qstring+'\n') });
sock.on('data', msg => {
ansString += msg.toString();
}).on('end', () => {
ansString = ansString.trim();
try {
resolve(ansString.length ? JSON.parse(ansString) : { ok: "empty" });
} catch (jsonErr) {
log.debug("query: "+qstring+"\nanswer: "+ansString+"\nerror: "+jsonErr);
resolve({ ok: ansString })
}
}).on('error', err => {
log.debug("Socket error: "+err);
reject(err);
});
})).catch(ans => ans && (ans.defunct || ans.err) ? { ...ans, defunct: true } : { defunct: true, err: ans });
// Variant of command that attaches more informatione to answer.
intf.statusCommand = (qstring, query) => intf.command(qstring, query || { }).then(ans => {
if (ans.ok === 'empty' || query.info_id === undefined || query.info_id === intf.id) { return ans }
if (images) { ans.images = images }
ans.path = item.path;
ans.tracks = tracks;
ans.tracklist_off = intf.tracklist_off;
ans.artist = artist;
ans.album = album;
ans.info_id = intf.id;
ans.new_info = true;
return ans;
});
// Skips ahead or backwards a number of tracks.
const skip = (query, step) => {
pausedSkip = intf.pausedStatus();
let qstring = 'cmd=skip&step='+step;
for (const a of ['from_track', 'from_time', 'dry']) {
const v = query && query[a];
if (v !== undefined) { qstring += '&'+a+'='+v }
}
return intf.command(qstring);
}
// Next track.
intf.httpCallable.next = (qstring, query) => skip(query, 1);
// Prev restarts current track, unless in paused-skip mode or repeated within 1s.
intf.httpCallable.prev = (qstring, query) => skip(query, !pausedSkip && !(query.rept < 1000) ? 0 : -1);
// Status command.
intf.httpCallable.get_status = (qstring, query) => intf.statusCommand(qstring || 'cmd=get_status', query);
// Commands passed on verbatim.
for (const cmd of ['seeka', 'seekr']) { intf.httpCallable[cmd] = intf.command }
// Command to repeat from first track.
intf.httpCallable.repeat = () => intf.command("cmd=seeka&track="+Math.max(0, 1-intf.tracklist_off)+"&time=0");
return intf.ready();
}
Version: v4.3.2.2 (2026-05-16T17:03:34+02:00)
Raw file
Source code overview
Klipspringer home