experimental AudioProxy
This commit is contained in:
@@ -11,7 +11,7 @@ const process = wtvshared.process;
|
|||||||
const util = wtvshared.util;
|
const util = wtvshared.util;
|
||||||
const nunjucks = require('nunjucks');
|
const nunjucks = require('nunjucks');
|
||||||
const {serialize, unserialize} = require('php-serialize');
|
const {serialize, unserialize} = require('php-serialize');
|
||||||
const {spawn} = require('child_process');
|
const {spawn, spawnSync} = require('child_process');
|
||||||
const http = require('follow-redirects').http;
|
const http = require('follow-redirects').http;
|
||||||
const httpx = require(classPath + "/HTTPX.js");
|
const httpx = require(classPath + "/HTTPX.js");
|
||||||
const { URL } = require('url');
|
const { URL } = require('url');
|
||||||
@@ -27,6 +27,7 @@ const WTVClientSessionData = require(classPath + "/WTVClientSessionData.js");
|
|||||||
const WTVMime = require(classPath + "/WTVMime.js");
|
const WTVMime = require(classPath + "/WTVMime.js");
|
||||||
const WTVFlashrom = require(classPath + "/WTVFlashrom.js");
|
const WTVFlashrom = require(classPath + "/WTVFlashrom.js");
|
||||||
const WTVImage = require(classPath + "/WTVImage.js");
|
const WTVImage = require(classPath + "/WTVImage.js");
|
||||||
|
const WTVAudioProxy = require(classPath + "/WTVAudioProxy.js");
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const debug = require('debug')('app');
|
const debug = require('debug')('app');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -37,6 +38,32 @@ const protocolServers = [];
|
|||||||
|
|
||||||
const minisrv_config = wtvshared.getMiniSrvConfig(); // snatches minisrv_config
|
const minisrv_config = wtvshared.getMiniSrvConfig(); // snatches minisrv_config
|
||||||
const wtvmime = new WTVMime(minisrv_config);
|
const wtvmime = new WTVMime(minisrv_config);
|
||||||
|
const wtvAudioProxy = new WTVAudioProxy(minisrv_config);
|
||||||
|
|
||||||
|
function validateAudioProxy() {
|
||||||
|
if (!wtvAudioProxy || !wtvAudioProxy.isEnabled()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const check = spawnSync(wtvAudioProxy.config.ffmpegPath, ['-hide_banner', '-version'], {
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore'],
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (check.error || check.status !== 0) {
|
||||||
|
console.warn(`AudioProxy disabled: ffmpeg not found or failed to execute at '${wtvAudioProxy.config.ffmpegPath}'.`);
|
||||||
|
if (check.error) console.warn(`AudioProxy ffmpeg error: ${check.error.message}`);
|
||||||
|
wtvAudioProxy.config.enabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`AudioProxy enabled: transcoding audio to ${wtvAudioProxy.config.bitrate} ${wtvAudioProxy.config.sampleRate}Hz mono MP3.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`AudioProxy disabled: ffmpeg validation failed: ${error.message}`);
|
||||||
|
wtvAudioProxy.config.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateAudioProxy();
|
||||||
|
|
||||||
process
|
process
|
||||||
.on('SIGTERM', shutdown('SIGTERM'))
|
.on('SIGTERM', shutdown('SIGTERM'))
|
||||||
@@ -1419,6 +1446,27 @@ async function sendToClient(socket, headers_obj, data = null, throttle = 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wtvAudioProxy && wtvAudioProxy.isEnabled()) {
|
||||||
|
const contype_key = wtvshared.getCaseInsensitiveKey('content-type', headers_obj);
|
||||||
|
if (contype_key && wtvAudioProxy.shouldProxy(headers_obj[contype_key])) {
|
||||||
|
try {
|
||||||
|
const transformResult = await wtvAudioProxy.transformIfNeeded(headers_obj, data);
|
||||||
|
headers_obj = transformResult.headers;
|
||||||
|
data = transformResult.data;
|
||||||
|
content_length = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data || '');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Audio proxy error:', e.message);
|
||||||
|
headers_obj = {
|
||||||
|
"Status": "413 Audio Too Long",
|
||||||
|
"Content-type": "text/plain"
|
||||||
|
};
|
||||||
|
data = (e.code === 'AUDIO_TOO_LONG') ?
|
||||||
|
`Audio exceeds maximum allowed duration of ${wtvAudioProxy.config.maxDurationSeconds} seconds.` :
|
||||||
|
`Audio proxy failure: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// if client can do compression, see if its worth enabling
|
// if client can do compression, see if its worth enabling
|
||||||
// small files actually get larger, so don't compress them
|
// small files actually get larger, so don't compress them
|
||||||
|
|||||||
167
zefie_wtvp_minisrv/includes/classes/WTVAudioProxy.js
Normal file
167
zefie_wtvp_minisrv/includes/classes/WTVAudioProxy.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
class WTVHTTP {
|
class WTVHTTP {
|
||||||
constructor(...[minisrv_config, service_name, wtvshared, sendToClient, http]) {
|
constructor(...[minisrv_config, service_name, wtvshared, sendToClient, net, http]) {
|
||||||
this.minisrv_config = minisrv_config;
|
this.minisrv_config = minisrv_config;
|
||||||
this.service_name = service_name;
|
this.service_name = service_name;
|
||||||
this.wtvshared = wtvshared;
|
this.wtvshared = wtvshared;
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
"image/webp"
|
"image/webp"
|
||||||
],
|
],
|
||||||
"image_options": {
|
"image_options": {
|
||||||
"compressionLevel": 0,
|
"compressionLevel": 0, // for png, anything higher than 0 is just wasting CPU time for minisrv's use case
|
||||||
"adaptiveFiltering": true,
|
"adaptiveFiltering": true,
|
||||||
"dither": 1,
|
"dither": 1,
|
||||||
"colors": 256,
|
"colors": 256,
|
||||||
@@ -138,6 +138,27 @@
|
|||||||
"max_file_size": 524288,
|
"max_file_size": 524288,
|
||||||
"jpeg_interval": 5, // lower quality by this amount to try to lower filesize
|
"jpeg_interval": 5, // lower quality by this amount to try to lower filesize
|
||||||
"max_quality_tries": 5 // try to decode up to this many times, reducing quality each time, until the file is under the max_file_size. After this many tries, it will error out rather than sending an oversized file to the client.
|
"max_quality_tries": 5 // try to decode up to this many times, reducing quality each time, until the file is under the max_file_size. After this many tries, it will error out rather than sending an oversized file to the client.
|
||||||
|
},
|
||||||
|
"audio_proxy": {
|
||||||
|
"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": "32k",
|
||||||
|
"sampleRate": 22050,
|
||||||
|
"channels": 1,
|
||||||
|
"maxDurationSeconds": 480,
|
||||||
|
"ffmpegPath": "ffmpeg"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
|
|||||||
Reference in New Issue
Block a user