// 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 path from 'path';
import { log } from './log.js';
import { readdir, mkdir, unlink, open, stat } from 'fs/promises';
import { spawn } from 'child_process';
import { recButtons } from './svg_images.js';
export const decodeFormat = (fnam) => {
const m = fnam.match(/^((kliprec@(\d\d\d\d)(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)\.(\d\d\d)) -r (\d+) -b (\d+)(?:\+(\d+))? -c (\d+) -e (un)?signed-integer -(B|L)\.raw)?$/);
let i = 0;
return m && {
fullfile: m[++i],
file: m[++i],
date: m[++i]+"-"+m[++i]+"-"+m[++i]+" "+m[++i]+":"+m[++i]+":"+m[++i]+"."+m[++i],
rate: +m[++i],
bits: +m[++i],
pad: +(m[++i] || 0),
channels: +m[++i],
signed: !m[++i],
bigend: m[++i] === "L"
}
}
const formatInfo = (format) => {
switch ((format || "raw").toLowerCase()) {
case 'raw':
return [true, ".raw", "application/octet-stream"];
case 'flac':
return [false, ".flac", "audio/flac"];
case 'wav':
return [false, ".wav", "audio/wav"];
case 'ogg':
return [false, ".ogg", "audio/ogg"];
case 'mp3':
return [false, ".mp3", "audio/mpeg"];
default:
return [false, format, null]
}
}
const writeFile = (raw, ext, mime, props, opts, resp) => {
if (!mime) {
resp.httpError("Cannot convert to "+ext+" format");
return;
}
const rawfile = path.join(opts.recdir, props.fullfile);
const file = raw ? rawfile : path.join(opts.recdir, "cache", props.file+ext);
return stat(file).catch(() => {
// Stat failed, need to create the file.
if (raw) { return Promise.reject("Recording not found") }
const args = [...opts.java_opts];
args.push("net.avadeaux.klipspringer.EncodeFile", rawfile, file);
return new Promise((resolve, reject) => {
spawn(opts.java, args).on('exit', (status, signal) => {
if (status === 0) {
resolve(stat(file));
} else {
log.debug("Recording encoding failed: "+status+"/"+signal+" ("+args.join(" ")+")");
reject("Converting recording to "+ext+" format failed");
}
});
});
}).then(encstat => {
resp.writeHead(200, {
"Content-Type": mime,
"Content-Disposition": 'attachment; filename="'+(raw ? props.fullfile : props.file+ext)+'"',
"Content-Length": encstat.size
});
if (!resp.expectsBody()) { resp.end(); return }
return open(file).then(fh => {
fh.createReadStream().on('data', buf => {
resp.write(buf, 'binary');
}).on('end', () => {
resp.end('', 'binary');
});
});
}).catch(err => {
resp.httpError(err.toString());
});
}
let setupPromise = { then: () => Promise.reject("setupRecordings not called") }
const fileProps = (file, opts) => setupPromise
.then(() => readdir(opts.recdir))
.then(files => {
for (const f of files) {
const props = decodeFormat(f) || { };
if (props.file === file) { return props }
}
return Promise.reject("File not found");
});
export const recordingFileName = (file, opts) => fileProps(file, opts).then(props => props.fullfile);
export const setupRecordings = (opts) => {
const libSupport = new Promise((resolve, reject) => {
const proc = spawn(path.join(opts.lib, "bin", "libsupport_json"));
let out = "";
proc.stdout.on('data', buf => {
out += buf.toString();
});
proc.stderr.on('data', buf => {
log.error("Error reading library support: "+buf.toString());
});
proc.on('exit', (status, signal) => {
try {
if (status === 0) {
resolve(JSON.parse(out));
} else {
reject(status+"/"+signal);
}
} catch (err) { reject(err) }
});
});
const cachedir = path.join(opts.recdir, "cache");
const clearCache = () => readdir(cachedir)
.then(files => { for (const f of files) { unlink(path.join(cachedir, f)) } })
.catch(err => { log.debug("Error emptying recordings cache: "+err) })
.then(() => mkdir(cachedir, { recursive: true }))
.catch(err => { log.debug("Error in mkdir recs directories: "+err) });
setupPromise = clearCache();
return {
browse: async (query, resp) => {
await setupPromise;
const recs = [];
for (const f of await readdir(opts.recdir)) {
const props = decodeFormat(f);
if (!props) { continue }
props.size = (await stat(path.join(opts.recdir, f))).size;
delete props.fullfile;
recs.push(props);
}
if (recs.length === 0) { resp.httpError("No recordings found"); return }
recs.sort((a, b) => a.date < b.date ? -1 : (a.date === b.date ? 0 : 1));
return resp.passProcessedTextFile('html', 'recordings.html', {
...query,
...recButtons,
...await libSupport,
default_download_format: '"'+opts.default_download_format+'"',
recs: JSON.stringify(recs)
});
},
intf: {
unlink: (query, resp) => fileProps(query.file, opts)
.then(props => unlink(path.join(opts.recdir, props.fullfile)))
.then(() => ({ ok: true }))
.catch(err => ({ err: err }))
.then(ans => resp.passObj(ans)),
download: (query, resp) => fileProps(query.file, opts)
.then(props => writeFile(...formatInfo(query.format || opts.default_download_format), props, opts, resp)),
clearcache: (query, resp) => clearCache()
}
}
}
Version: v4.3.2.2 (2026-05-16T17:03:34+02:00)
Raw file
Source code overview
Klipspringer home