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