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