Files
minisrv/zefie_wtvp_minisrv/includes/classes/WTVAudioProxy.js
2026-04-30 00:59:29 -04:00

168 lines
5.9 KiB
JavaScript

'use strict';
const { spawn } = require('child_process');
class WTVAudioProxy {
constructor(minisrv_config) {
this.minisrv_config = minisrv_config || {};
const audioProxyConfig = (minisrv_config && minisrv_config.config && minisrv_config.config.audio_proxy) ? minisrv_config.config.audio_proxy : {};
this.config = Object.assign({
enabled: false,
types: [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/ogg',
'audio/x-wav',
'audio/flac',
'audio/x-flac',
'audio/aac',
'audio/mp4',
'audio/x-m4a',
'audio/x-ms-wma'
],
bitrate: '20k',
sampleRate: 16000,
channels: 1,
maxDurationSeconds: 480,
ffmpegPath: 'ffmpeg'
}, audioProxyConfig);
if (!Array.isArray(this.config.types)) {
this.config.types = [];
}
this.config.types = this.config.types.map((t) => String(t).toLowerCase().trim()).filter(Boolean);
this.config.bitrate = String(this.config.bitrate || '20k');
this.config.sampleRate = parseInt(this.config.sampleRate, 10) || 16000;
this.config.channels = parseInt(this.config.channels, 10) || 1;
this.config.maxDurationSeconds = parseInt(this.config.maxDurationSeconds, 10) || 480;
this.config.ffmpegPath = String(this.config.ffmpegPath || 'ffmpeg');
}
isEnabled() {
return this.config.enabled === true;
}
normalizeContentType(contentType) {
if (!contentType) return '';
return contentType.split(';')[0].trim().toLowerCase();
}
shouldProxy(contentType) {
if (!this.isEnabled()) return false;
const normalized = this.normalizeContentType(contentType);
return normalized && this.config.types.includes(normalized);
}
async inspectDuration(sourceData) {
return new Promise((resolve, reject) => {
const args = [
'-hide_banner',
'-nostdin',
'-i',
'pipe:0',
'-f',
'null',
'-'
];
const ffprobe = spawn(this.config.ffmpegPath, args, { stdio: ['pipe', 'ignore', 'pipe'] });
let stderr = '';
ffprobe.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
ffprobe.on('error', (err) => reject(err));
ffprobe.on('close', (code) => {
const matches = stderr.match(/Duration:\s*([0-9]{2}):([0-9]{2}):([0-9]{2}\.[0-9]+)/);
if (matches) {
const hours = parseInt(matches[1], 10);
const minutes = parseInt(matches[2], 10);
const seconds = parseFloat(matches[3]);
return resolve(hours * 3600 + minutes * 60 + seconds);
}
if (code === 0) {
return resolve(0);
}
return reject(new Error(`ffmpeg failed to inspect media: ${stderr.trim()}`));
});
ffprobe.stdin.end(sourceData);
});
}
async transcode(sourceData) {
return new Promise((resolve, reject) => {
const args = [
'-hide_banner',
'-nostdin',
'-y',
'-i',
'pipe:0',
'-vn',
'-acodec',
'libmp3lame',
'-b:a',
this.config.bitrate,
'-ar',
String(this.config.sampleRate),
'-ac',
String(this.config.channels),
'-f',
'mp3',
'pipe:1'
];
const ffmpeg = spawn(this.config.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] });
const outputChunks = [];
let stderr = '';
ffmpeg.stdout.on('data', (chunk) => outputChunks.push(chunk));
ffmpeg.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
ffmpeg.on('error', (err) => reject(err));
ffmpeg.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`ffmpeg transcode failed (${code}): ${stderr.trim()}`));
}
resolve(Buffer.concat(outputChunks));
});
ffmpeg.stdin.end(sourceData);
});
}
async transformIfNeeded(headers, data) {
if (!headers || !this.shouldProxy(headers['Content-type'])) {
return { headers, data };
}
if (headers['Content-Encoding'] && headers['Content-Encoding'].toLowerCase() !== 'identity') {
return { headers, data };
}
const sourceBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data || '');
if (sourceBuffer.length === 0) {
return { headers, data: sourceBuffer };
}
const duration = await this.inspectDuration(sourceBuffer);
if (duration > this.config.maxDurationSeconds) {
const error = new Error(`Audio duration ${duration.toFixed(1)}s exceeds maximum of ${this.config.maxDurationSeconds}s`);
error.code = 'AUDIO_TOO_LONG';
throw error;
}
const converted = await this.transcode(sourceBuffer);
const originalType = this.normalizeContentType(headers['Content-type']);
if (converted.length >= sourceBuffer.length && (originalType === 'audio/wav' || originalType === 'audio/mpeg')) {
return { headers, data: sourceBuffer };
}
const newHeaders = Object.assign({}, headers);
newHeaders['Content-type'] = 'audio/mpeg';
delete newHeaders['Content-Encoding'];
delete newHeaders['Content-Length'];
return { headers: newHeaders, data: converted };
}
}
module.exports = WTVAudioProxy;