1
0
mirror of https://github.com/KubaPro010/fm-dx-webserver.git synced 2026-02-26 22:13:53 +01:00

Compare commits

...

24 Commits

Author SHA1 Message Date
e14b3f8d11 some changes with the rds 2026-02-25 10:44:37 +01:00
42a20330af credit? 2026-02-24 22:28:04 +01:00
c0d1fee257 "too dig of a bick" 2026-02-24 15:27:39 +01:00
5d524eba56 some changes 2026-02-24 15:03:56 +01:00
648ef00bed sync to upstream 2026-02-24 14:44:48 +01:00
8a53bf1027 oh brother 2026-02-24 14:17:30 +01:00
722277c41f whoops 2026-02-24 14:16:50 +01:00
ee25214160 some changes again 2026-02-24 14:15:52 +01:00
1d04719580 some changes 2026-02-24 12:20:09 +01:00
1f70b58295 whoops 2026-02-24 10:01:10 +01:00
3080468415 oh 2026-02-24 09:54:20 +01:00
0ae484529d we're up to date with the terrible 2026-02-24 09:52:39 +01:00
Adam Wisher
098b6ba4e9 Fix PS exact match and update TX DB every 7 days 2026-02-24 09:18:06 +01:00
03ff93cd39 TOY FUCKING LANGUAGE 2026-02-23 23:17:55 +01:00
ebe9c8bbe8 toy language 2026-02-23 23:17:17 +01:00
df215edf9e toy language 2026-02-23 23:16:00 +01:00
410c39a6b8 Not sure which has better code, firmware, or this? This is okay, but toy lang. Firmware was horrible. 2026-02-23 23:15:15 +01:00
d0a26d2346 what is this option 2026-02-23 18:07:15 +01:00
0a7c56ff1d playaround with settings 2026-02-23 18:00:50 +01:00
5a321562bd lower sample rate 2026-02-23 17:56:24 +01:00
915f2c7e58 increase it further 2026-02-23 17:54:27 +01:00
6cabd1a75a increase buffer 2026-02-23 17:52:46 +01:00
028cd2e587 Re-design the audio engine 2026-02-23 17:48:35 +01:00
Marek Farkaš
32782d6704 chat modularization 2026-02-08 20:54:23 +01:00
53 changed files with 2492 additions and 2534 deletions

4
.gitignore vendored
View File

@@ -1,8 +1,8 @@
node_modules/
/*.json
/serverlog.txt
/serverlog*.txt
/web/js/plugins/
/libraries/**
/libraries/
/plugins/*
!/plugins/example/frontend.js
!/plugins/example.js

View File

@@ -31,9 +31,7 @@ FM-DX Webserver is a cross-platform web server designed for FM DXers who want to
This project utilizes these libraries:
- [3LAS](https://github.com/jojobond/3LAS) library by JoJoBond for Low Latency Audio Streaming.
- [flat-flags](https://github.com/luishdez/flat-flags) library by luishdez for RDS country flags.
- [librdsparser](https://github.com/kkonradpl/librdsparser) library by Konrad Kosmatka for RDS parsing.
All of these libraries are already bundled with the webserver.

View File

@@ -3,7 +3,7 @@ require('./server/index.js');
/**
* FM-DX Webserver
*
* Github repo: https://github.com/NoobishSVK/fm-dx-webserver
* Github repo: https://github.com/KubaPro010/fm-dx-webserver
* Server files: /server
* Client files (web): /web
* Plugin files: /plugins

618
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "fm-dx-webserver",
"version": "1.3.12",
"version": "1.4.0a",
"description": "FM DX Webserver",
"main": "index.js",
"scripts": {
@@ -12,16 +12,17 @@
"author": "",
"license": "ISC",
"dependencies": {
"@mapbox/node-pre-gyp": "2.0.0",
"body-parser": "2.2.0",
"ejs": "3.1.10",
"express": "5.1.0",
"express-session": "1.18.2",
"ffmpeg-static": "5.2.0",
"@mapbox/node-pre-gyp": "2.0.3",
"body-parser": "2.2.2",
"ejs": "4.0.1",
"express": "5.2.1",
"express-session": "1.19.0",
"ffmpeg-static": "5.3.0",
"figlet": "^1.10.0",
"http": "0.0.1-security",
"koffi": "2.7.2",
"net": "1.0.2",
"serialport": "12.0.0",
"ws": "8.18.1"
"serialport": "13.0.0",
"ws": "8.19.0"
}
}

124
server/chat.js Normal file
View File

@@ -0,0 +1,124 @@
const WebSocket = require('ws');
const { serverConfig } = require('./server_config');
const { logChat } = require('./console');
const helpers = require('./helpers');
function heartbeat() { // WebSocket heartbeat helper
this.isAlive = true;
}
function createChatServer(storage) {
if (!serverConfig.webserver.chatEnabled) return null;
const chatWss = new WebSocket.Server({ noServer: true });
chatWss.on('connection', (ws, request) => {
ws.isAlive = true;
ws.on('pong', heartbeat);
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
// Send chat history safely
storage.chatHistory.forEach((message) => {
const historyMessage = { ...message, history: true };
if (!request.session?.isAdminAuthenticated) delete historyMessage.ip;
ws.send(JSON.stringify(historyMessage));
});
ws.send(JSON.stringify({
type: 'clientIp',
ip: clientIp,
admin: request.session?.isAdminAuthenticated
}));
const userCommands = {};
let lastWarn = { time: 0 };
ws.on('message', (message) => {
message = helpers.antispamProtection(
message,
clientIp,
ws,
userCommands,
lastWarn,
userCommandHistory,
'5',
'chat',
512
);
if(!message) return;
let messageData;
try {
messageData = JSON.parse(message);
} catch {
ws.send(JSON.stringify({ error: "Invalid message format" }));
return;
}
delete messageData.admin;
delete messageData.ip;
delete messageData.time;
if (messageData.nickname != null) messageData.nickname = helpers.escapeHtml(String(messageData.nickname));
messageData.ip = clientIp;
const now = new Date();
messageData.time = String(now.getHours()).padStart(2, '0') + ":" + String(now.getMinutes()).padStart(2, '0');
if (serverConfig.webserver.banlist?.includes(clientIp)) return;
if (request.session?.isAdminAuthenticated === true) messageData.admin = true;
if (messageData.nickname?.length > 32) messageData.nickname = messageData.nickname.substring(0, 32);
if (messageData.message?.length > 255) messageData.message = messageData.message.substring(0, 255);
storage.chatHistory.push(messageData);
if (storage.chatHistory.length > 50) storage.chatHistory.shift();
logChat(messageData);
chatWss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
const responseMessage = { ...messageData };
if (!request.session?.isAdminAuthenticated) delete responseMessage.ip;
client.send(JSON.stringify(responseMessage));
}
});
});
ws.on('close', () => {
ws.isAlive = false;
});
});
/**
* We will not always be receiving data, so some proxies may terminate the connection, this prevents it.
*/
const interval = setInterval(() => {
chatWss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
chatWss.on('close', () => {
clearInterval(interval);
});
return chatWss;
}
module.exports = { createChatServer };

View File

@@ -3,7 +3,9 @@ const fs = require('fs').promises;
const verboseMode = process.argv.includes('--debug');
const verboseModeFfmpeg = process.argv.includes('--ffmpegdebug');
const LOG_FILE = 'serverlog.txt';
const LOG_FILE = process.argv.includes('--config') && process.argv[process.argv.indexOf('--config') + 1]
? `serverlog_${process.argv[process.argv.indexOf('--config') + 1]}.txt`
: 'serverlog.txt';
const ANSI_ESCAPE_CODE_PATTERN = /\x1b\[[0-9;]*m/g;
const MAX_LOG_LINES = 5000;
const FLUSH_INTERVAL = 60000;
@@ -30,21 +32,19 @@ const getCurrentTime = () => {
const removeANSIEscapeCodes = (str) => str.replace(ANSI_ESCAPE_CODE_PATTERN, ''); // Strip ANSI escape codes from a string
const logMessage = (type, messages, verbose = false) => {
const logMessage = (type, messages) => {
const logMessage = `${getCurrentTime()} ${MESSAGE_PREFIX[type]} ${messages.join(' ')}`;
if (type === 'DEBUG' && verboseMode || type === 'FFMPEG' && verboseModeFfmpeg || type !== 'DEBUG' && type !== 'FFMPEG') {
if ((type === 'DEBUG' && verboseMode) || (type === 'FFMPEG' && verboseModeFfmpeg) || type !== 'DEBUG' && type !== 'FFMPEG') {
logs.push(logMessage);
if (logs.length > maxConsoleLogLines) logs.shift();
console.log(logMessage);
}
if(type !== 'FFMPEG') {
appendLogToBuffer(logMessage);
}
if(type !== 'FFMPEG') appendLogToBuffer(logMessage);
};
const logDebug = (...messages) => logMessage('DEBUG', messages, verboseMode);
const logDebug = (...messages) => logMessage('DEBUG', messages);
const logChat = (message) => logMessage('CHAT', [`${message.nickname} (${message.ip}) sent a chat message: ${message.message}`]);
const logError = (...messages) => logMessage('ERROR', messages);
const logFfmpeg = (...messages) => logMessage('FFMPEG', messages, verboseModeFfmpeg);

View File

@@ -1,219 +1,14 @@
/* Libraries / Imports */
const fs = require('fs');
const https = require('https');
const koffi = require('koffi');
const path = require('path');
const os = require('os');
const platform = os.platform();
const cpuArchitecture = os.arch();
const { configName, serverConfig, configUpdate, configSave } = require('./server_config');
let unicode_type;
let shared_Library;
if (platform === 'win32') {
unicode_type = 'int16_t';
arch_type = (cpuArchitecture === 'x64' ? 'mingw64' : 'mingw32');
shared_Library=path.join(__dirname, "libraries", arch_type, "librdsparser.dll");
} else if (platform === 'linux') {
unicode_type = 'int32_t';
arch_type = (cpuArchitecture === 'x64' ? 'x86_64' :
(cpuArchitecture === 'ia32' ? 'x86' :
(cpuArchitecture === 'arm64' ? 'aarch64' : cpuArchitecture)));
shared_Library=path.join(__dirname, "libraries", arch_type, "librdsparser.so");
} else if (platform === 'darwin') {
unicode_type = 'int32_t';
shared_Library=path.join(__dirname, "libraries", "macos", "librdsparser.dylib");
}
const lib = koffi.load(shared_Library);
const { fetchTx } = require('./tx_search.js');
koffi.proto('void callback_pi(void *rds, void *user_data)');
koffi.proto('void callback_pty(void *rds, void *user_data)');
koffi.proto('void callback_tp(void *rds, void *user_data)');
koffi.proto('void callback_ta(void *rds, void *user_data)');
koffi.proto('void callback_ms(void *rds, void *user_data)');
koffi.proto('void callback_ecc(void *rds, void *user_data)');
koffi.proto('void callback_country(void *rds, void *user_data)');
koffi.proto('void callback_af(void *rds, uint32_t af, void *user_data)');
koffi.proto('void callback_ps(void *rds, void *user_data)');
koffi.proto('void callback_rt(void *rds, int flag, void *user_data)');
koffi.proto('void callback_ptyn(void *rds, void *user_data)');
koffi.proto('void callback_ct(void *rds, void *ct, void *user_data)');
const rdsparser = {
new: lib.func('void* rdsparser_new()'),
free: lib.func('void rdsparser_free(void *rds)'),
clear: lib.func('void rdsparser_clear(void *rds)'),
parse_string: lib.func('bool rdsparser_parse_string(void *rds, const char *input)'),
set_text_correction: lib.func('void rdsparser_set_text_correction(void *rds, uint8_t text, uint8_t type, uint8_t error)'),
set_text_progressive: lib.func('void rdsparser_set_text_progressive(void *rds, uint8_t string, uint8_t state)'),
get_pi: lib.func('int32_t rdsparser_get_pi(void *rds)'),
get_pty: lib.func('int8_t rdsparser_get_pty(void *rds)'),
get_tp: lib.func('int8_t rdsparser_get_tp(void *rds)'),
get_ta: lib.func('int8_t rdsparser_get_ta(void *rds)'),
get_ms: lib.func('int8_t rdsparser_get_ms(void *rds)'),
get_ecc: lib.func('int16_t rdsparser_get_ecc(void *rds)'),
get_country: lib.func('int rdsparser_get_country(void *rds)'),
get_ps: lib.func('void* rdsparser_get_ps(void *rds)'),
get_rt: lib.func('void* rdsparser_get_rt(void *rds, int flag)'),
get_ptyn: lib.func('void* rdsparser_get_ptyn(void *rds)'),
register_pi: lib.func('void rdsparser_register_pi(void *rds, void *cb)'),
register_pty: lib.func('void rdsparser_register_pty(void *rds, void *cb)'),
register_tp: lib.func('void rdsparser_register_tp(void *rds, void *cb)'),
register_ta: lib.func('void rdsparser_register_ta(void *rds, void *cb)'),
register_ms: lib.func('void rdsparser_register_ms(void *rds, void *cb)'),
register_ecc: lib.func('void rdsparser_register_ecc(void *rds, void *cb)'),
register_country: lib.func('void rdsparser_register_country(void *rds, void *cb)'),
register_af: lib.func('void rdsparser_register_af(void *rds, void *cb)'),
register_ps: lib.func('void rdsparser_register_ps(void *rds, void *cb)'),
register_rt: lib.func('void rdsparser_register_rt(void *rds, void *cb)'),
register_ptyn: lib.func('void rdsparser_register_ptyn(void *rds, void *cb)'),
register_ct: lib.func('void rdsparser_register_ct(void *rds, void *cb)'),
string_get_content: lib.func(unicode_type + '* rdsparser_string_get_content(void *string)'),
string_get_errors: lib.func('uint8_t* rdsparser_string_get_errors(void *string)'),
string_get_length: lib.func('uint8_t rdsparser_string_get_length(void *string)'),
ct_get_year: lib.func('uint16_t rdsparser_ct_get_year(void *ct)'),
ct_get_month: lib.func('uint8_t rdsparser_ct_get_month(void *ct)'),
ct_get_day: lib.func('uint8_t rdsparser_ct_get_day(void *ct)'),
ct_get_hour: lib.func('uint8_t rdsparser_ct_get_hour(void *ct)'),
ct_get_minute: lib.func('uint8_t rdsparser_ct_get_minute(void *ct)'),
ct_get_offset: lib.func('int8_t rdsparser_ct_get_offset(void *ct)'),
pty_lookup_short: lib.func('const char* rdsparser_pty_lookup_short(int8_t pty, bool rbds)'),
pty_lookup_long: lib.func('const char* rdsparser_pty_lookup_long(int8_t pty, bool rbds)'),
country_lookup_name: lib.func('const char* rdsparser_country_lookup_name(int country)'),
country_lookup_iso: lib.func('const char* rdsparser_country_lookup_iso(int country)')
}
const callbacks = {
pi: koffi.register(rds => (
value = rdsparser.get_pi(rds)
//console.log('PI: ' + value.toString(16).toUpperCase())
), 'callback_pi*'),
pty: koffi.register(rds => (
value = rdsparser.get_pty(rds),
dataToSend.pty = value
), 'callback_pty*'),
tp: koffi.register(rds => (
value = rdsparser.get_tp(rds),
dataToSend.tp = value
), 'callback_tp*'),
ta: koffi.register(rds => (
value = rdsparser.get_ta(rds),
dataToSend.ta = value
), 'callback_ta*'),
ms: koffi.register(rds => (
value = rdsparser.get_ms(rds),
dataToSend.ms = value
), 'callback_ms*'),
af: koffi.register((rds, value) => (
dataToSend.af.push(value)
), 'callback_af*'),
ecc: koffi.register(rds => (
value = rdsparser.get_ecc(rds),
dataToSend.ecc = value
), 'callback_ecc*'),
country: koffi.register(rds => (
value = rdsparser.get_country(rds),
display = rdsparser.country_lookup_name(value),
iso = rdsparser.country_lookup_iso(value),
dataToSend.country_name = display,
dataToSend.country_iso = iso
), 'callback_country*'),
ps: koffi.register(rds => (
ps = rdsparser.get_ps(rds),
dataToSend.ps = decode_unicode(ps),
dataToSend.ps_errors = decode_errors(ps)
), 'callback_ps*'),
rt: koffi.register((rds, flag) => {
const rt = rdsparser.get_rt(rds, flag);
if (flag === 0) {
dataToSend.rt0 = decode_unicode(rt);
dataToSend.rt0_errors = decode_errors(rt);
}
if (flag === 1) {
dataToSend.rt1 = decode_unicode(rt);
dataToSend.rt1_errors = decode_errors(rt);
}
dataToSend.rt_flag = flag;
}, 'callback_rt*'),
ptyn: koffi.register((rds, flag) => (
value = decode_unicode(rdsparser.get_ptyn(rds))
/*console.log('PTYN: ' + value)*/
), 'callback_ptyn*'),
ct: koffi.register((rds, ct) => (
year = rdsparser.ct_get_year(ct),
month = String(rdsparser.ct_get_month(ct)).padStart(2, '0'),
day = String(rdsparser.ct_get_day(ct)).padStart(2, '0'),
hour = String(rdsparser.ct_get_hour(ct)).padStart(2, '0'),
minute = String(rdsparser.ct_get_minute(ct)).padStart(2, '0'),
offset = rdsparser.ct_get_offset(ct),
tz_sign = (offset >= 0 ? '+' : '-'),
tz_hour = String(Math.abs(Math.floor(offset / 60))).padStart(2, '0'),
tz_minute = String(Math.abs(offset % 60)).padStart(2, '0')
//console.log('CT: ' + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ' (' + tz_sign + tz_hour + ':' + tz_minute + ')')
), 'callback_ct*')
};
let rds = rdsparser.new()
rdsparser.set_text_correction(rds, 0, 0, 2);
rdsparser.set_text_correction(rds, 0, 1, 2);
rdsparser.set_text_correction(rds, 1, 0, 2);
rdsparser.set_text_correction(rds, 1, 1, 2);
rdsparser.set_text_progressive(rds, 0, 1)
rdsparser.set_text_progressive(rds, 1, 1)
rdsparser.register_pi(rds, callbacks.pi);
rdsparser.register_pty(rds, callbacks.pty);
rdsparser.register_tp(rds, callbacks.tp);
rdsparser.register_ta(rds, callbacks.ta);
rdsparser.register_ms(rds, callbacks.ms);
rdsparser.register_ecc(rds, callbacks.ecc);
rdsparser.register_country(rds, callbacks.country);
rdsparser.register_af(rds, callbacks.af);
rdsparser.register_ps(rds, callbacks.ps);
rdsparser.register_rt(rds, callbacks.rt);
rdsparser.register_ptyn(rds, callbacks.ptyn);
rdsparser.register_ct(rds, callbacks.ct);
const decode_unicode = function(string) {
let length = rdsparser.string_get_length(string);
if (length) {
let content = rdsparser.string_get_content(string);
let array = koffi.decode(content, unicode_type + ' [' + length + ']');
return String.fromCodePoint.apply(String, array);
}
return '';
};
const decode_errors = function(string) {
let length = rdsparser.string_get_length(string);
if (length) {
let errors = rdsparser.string_get_errors(string);
let array = koffi.decode(errors, 'uint8_t [' + length + ']');
return Uint8Array.from(array).toString();
}
return '';
};
const RDSDecoder = require("./rds.js");
const { serverConfig } = require('./server_config');
const fetchTx = require('./tx_search.js');
const updateInterval = 75;
// Initialize the data object
var dataToSend = {
pi: '?',
freq: 87.500.toFixed(3),
freq: (87.500).toFixed(3),
sig: 0,
sigRaw: '',
sigTop: -Infinity,
@@ -233,6 +28,7 @@ var dataToSend = {
rt_flag: '',
ims: 0,
eq: 0,
agc: 0,
ant: 0,
txInfo: {
tx: '',
@@ -251,6 +47,8 @@ var dataToSend = {
users: 0,
};
const rdsdec = new RDSDecoder(dataToSend);
const filterMappings = {
'G11': { eq: 1, ims: 1 },
'G01': { eq: 0, ims: 1 },
@@ -264,8 +62,6 @@ var lastUpdateTime = Date.now();
const initialData = { ...dataToSend };
const resetToDefault = dataToSend => Object.assign(dataToSend, initialData);
// Serialport reconnect variables
const ServerStartTime = process.hrtime();
var serialportUpdateTime = process.hrtime();
let checkSerialport = false;
let rdsTimeoutTimer = null;
@@ -275,15 +71,13 @@ function rdsReceived() {
clearTimeout(rdsTimeoutTimer);
rdsTimeoutTimer = null;
}
if (serverConfig.webserver.rdsTimeout && serverConfig.webserver.rdsTimeout != 0) {
rdsTimeoutTimer = setTimeout(rdsReset, serverConfig.webserver.rdsTimeout * 1000);
}
if (serverConfig.webserver.rdsTimeout && serverConfig.webserver.rdsTimeout != 0) rdsTimeoutTimer = setTimeout(rdsReset, serverConfig.webserver.rdsTimeout * 1000);
}
function rdsReset() {
resetToDefault(dataToSend);
dataToSend.af.length = 0;
rdsparser.clear(rds);
rdsdec.clear();
if (rdsTimeoutTimer) {
clearTimeout(rdsTimeoutTimer);
rdsTimeoutTimer = null;
@@ -307,17 +101,13 @@ function handleData(wss, receivedData, rdsWss) {
rdsReceived();
modifiedData = receivedLine.slice(1);
legacyRdsPiBuffer = modifiedData;
if (dataToSend.pi.length >= modifiedData.length || dataToSend.pi == '?') {
dataToSend.pi = modifiedData;
}
if (dataToSend.pi.length >= modifiedData.length || dataToSend.pi == '?') dataToSend.pi = modifiedData;
break;
case receivedLine.startsWith('T'): // Frequency
modifiedData = receivedLine.substring(1).split(",")[0];
rdsReset();
if((modifiedData / 1000).toFixed(3) == dataToSend.freq) {
return; // Prevent tune spamming using scrollwheel
}
if((modifiedData / 1000).toFixed(3) == dataToSend.freq) return; // Prevent tune spamming using scrollwheel
parsedValue = parseFloat(modifiedData);
@@ -337,6 +127,10 @@ function handleData(wss, receivedData, rdsWss) {
initialData.ant = receivedLine.substring(1);
rdsReset();
break;
case receivedLine.startsWith('A'): // AGC
dataToSend.agc = receivedLine.substring(1);
initialData.agc = receivedLine.substring(1);
break;
case receivedLine.startsWith('G'): // EQ / iMS (RF+/IF+)
const mapping = filterMappings[receivedLine];
if (mapping) {
@@ -360,8 +154,8 @@ function handleData(wss, receivedData, rdsWss) {
processSignal(receivedLine, true, true);
break;
case receivedLine.startsWith('SM'):
processSignal(receivedLine, false, true);
break;
processSignal(receivedLine, false, true);
break;
case receivedLine.startsWith('R'): // RDS HEX
rdsReceived();
modifiedData = receivedLine.slice(1);
@@ -372,8 +166,7 @@ function handleData(wss, receivedData, rdsWss) {
var errorsNew = 0;
var pi;
if (legacyRdsPiBuffer !== null &&
legacyRdsPiBuffer.length >= 4) {
if(legacyRdsPiBuffer !== null && legacyRdsPiBuffer.length >= 4) {
pi = legacyRdsPiBuffer.slice(0, 4);
// PI message does not carry explicit information about
// error correction, but this is a good substitute.
@@ -392,19 +185,21 @@ function handleData(wss, receivedData, rdsWss) {
modifiedData += errorsNew.toString(16).padStart(2, '0');
}
const a = modifiedData.slice(0, 4);
const b = modifiedData.slice(4, 8);
const c = modifiedData.slice(8, 12);
const d = modifiedData.slice(12, 16);
const errors = parseInt(modifiedData.slice(-2), 16);
rdsWss.clients.forEach((client) => {
const errors = parseInt(modifiedData.slice(-2), 16);
let data = (((errors & 0xC0) == 0) ? modifiedData.slice(0, 4) : '----');
data += (((errors & 0x30) == 0) ? modifiedData.slice(4, 8) : '----');
data += (((errors & 0x0C) == 0) ? modifiedData.slice(8, 12) : '----');
data += (((errors & 0x03) == 0) ? modifiedData.slice(12, 16) : '----');
let data = ((((errors >> 6) & 3) < 3) ? a : '----');
data += ((((errors >> 4) & 3) < 3) ? b : '----');
data += ((((errors >> 2) & 3) < 3) ? c : '----');
data += (((errors & 3) < 3) ? d : '----');
const newDataString = "G:\r\n" + data + "\r\n\r\n";
const finalBuffer = Buffer.from(newDataString, 'utf-8');
client.send(finalBuffer);
client.send("G:\r\n" + data + "\r\n\r\n");
});
rdsparser.parse_string(rds, modifiedData);
rdsdec.decodeGroup(parseInt(a, 16), parseInt(b, 16), parseInt(c, 16), parseInt(d, 16), errors);
legacyRdsPiBuffer = null;
break;
}
@@ -434,15 +229,15 @@ function handleData(wss, receivedData, rdsWss) {
console.log("Error fetching Tx info:", error);
});
// Send the updated data to the client
const dataToSendJSON = JSON.stringify(dataToSend);
if (currentTime - lastUpdateTime >= updateInterval) {
wss.clients.forEach((client) => {
client.send(dataToSendJSON);
});
lastUpdateTime = Date.now();
serialportUpdateTime = process.hrtime();
}
// Send the updated data to the client
const dataToSendJSON = JSON.stringify(dataToSend);
if (currentTime - lastUpdateTime >= updateInterval) {
wss.clients.forEach((client) => {
client.send(dataToSendJSON);
});
lastUpdateTime = Date.now();
serialportUpdateTime = process.hrtime();
}
}
// Serialport retry code when port is open but communication is lost (additional code in index.js)
@@ -469,10 +264,7 @@ async function checkSerialPortStatus() {
while (!checkSerialport) {
const ServerElapsedSeconds = process.hrtime(ServerStartTime)[0];
if (ServerElapsedSeconds > 10) {
checkSerialport = true;
}
if (ServerElapsedSeconds > 10) checkSerialport = true;
await new Promise(resolve => setTimeout(resolve, 100));
}
}
@@ -507,11 +299,8 @@ function processSignal(receivedData, st, stForced) {
// Convert highestSignal to a number for comparison
var highestSignal = parseFloat(dataToSend.sigTop);
if (signal > highestSignal) {
dataToSend.sigTop = signal.toString(); // Convert back to string for consistency
}
}
if (signal > highestSignal) dataToSend.sigTop = signal.toString(); // Convert back to string for consistency
}
}
module.exports = {

View File

@@ -11,14 +11,15 @@ const { parseAudioDevice } = require('./stream/parser');
const { configName, serverConfig, configUpdate, configSave, configExists, configPath } = require('./server_config');
const helpers = require('./helpers');
const storage = require('./storage');
const { logInfo, logDebug, logWarn, logError, logFfmpeg, logs } = require('./console');
const tunerProfiles = require('./tuner_profiles');
const { logInfo, logs } = require('./console');
const dataHandler = require('./datahandler');
const fmdxList = require('./fmdx_list');
const { allPluginConfigs } = require('./plugins');
const allPluginConfigs = require('./plugins');
// Endpoints
router.get('/', (req, res) => {
let requestIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
let requestIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const normalizedIp = requestIp?.replace(/^::ffff:/, '');
const ipList = (normalizedIp || '').split(',').map(ip => ip.trim()).filter(Boolean); // in case there are multiple IPs (proxy), we need to check all of them
@@ -48,7 +49,13 @@ router.get('/', (req, res) => {
isAdminAuthenticated: true,
videoDevices: result.audioDevices,
audioDevices: result.videoDevices,
serialPorts: serialPorts
serialPorts: serialPorts,
serialPorts: serialPorts,
tunerProfiles: tunerProfiles.map((profile) => ({
id: profile.id,
label: profile.label,
detailsHtml: helpers.parseMarkdown(profile.details || '')
}))
});
});
});
@@ -68,6 +75,8 @@ router.get('/', (req, res) => {
tuningUpperLimit: serverConfig.webserver.tuningUpperLimit,
chatEnabled: serverConfig.webserver.chatEnabled,
device: serverConfig.device,
tunerProfiles,
si47xxAgcControl: !!serverConfig.si47xx?.agcControl,
noPlugins,
plugins: serverConfig.plugins,
fmlist_integration: serverConfig.extras.fmlistIntegration,
@@ -78,7 +87,8 @@ router.get('/', (req, res) => {
});
router.get('/403', (req, res) => {
res.render('403');
const reason = req.query.reason || null;
res.render('403', { reason });
})
router.get('/wizard', (req, res) => {
@@ -101,7 +111,12 @@ router.get('/wizard', (req, res) => {
isAdminAuthenticated: req.session.isAdminAuthenticated,
videoDevices: result.audioDevices,
audioDevices: result.videoDevices,
serialPorts: serialPorts
serialPorts: serialPorts,
tunerProfiles: tunerProfiles.map((profile) => ({
id: profile.id,
label: profile.label,
detailsHtml: helpers.parseMarkdown(profile.details || '')
}))
});
});
})
@@ -135,20 +150,25 @@ router.get('/wizard', (req, res) => {
const updatedConfig = loadConfig(); // Reload the config every time
res.render('setup', {
isAdminAuthenticated: req.session.isAdminAuthenticated,
videoDevices: result.audioDevices,
audioDevices: result.videoDevices,
serialPorts: serialPorts,
memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB',
memoryHeap: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) + ' MB',
processUptime: formattedProcessUptime,
consoleOutput: logs,
plugins: allPluginConfigs,
enabledPlugins: updatedConfig.plugins,
onlineUsers: dataHandler.dataToSend.users,
connectedUsers: storage.connectedUsers,
device: serverConfig.device,
banlist: updatedConfig.webserver.banlist // Updated banlist from the latest config
isAdminAuthenticated: req.session.isAdminAuthenticated,
videoDevices: result.audioDevices,
audioDevices: result.videoDevices,
serialPorts: serialPorts,
memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB',
memoryHeap: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) + ' MB',
processUptime: formattedProcessUptime,
consoleOutput: logs,
plugins: allPluginConfigs,
enabledPlugins: updatedConfig.plugins,
onlineUsers: dataHandler.dataToSend.users,
connectedUsers: storage.connectedUsers,
device: serverConfig.device,
banlist: updatedConfig.webserver.banlist, // Updated banlist from the latest config
tunerProfiles: tunerProfiles.map((profile) => ({
id: profile.id,
label: profile.label,
detailsHtml: helpers.parseMarkdown(profile.details || '')
}))
});
});
})
@@ -156,43 +176,37 @@ router.get('/wizard', (req, res) => {
router.get('/rds', (req, res) => {
res.send('Please connect using a WebSocket compatible app to obtain RDS stream.');
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
});
router.get('/rdsspy', (req, res) => {
res.send('Please connect using a WebSocket compatible app to obtain RDS stream.');
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
});
router.get('/api', (req, res) => {
const { ps_errors, rt0_errors, rt1_errors, ims, eq, ant, st_forced, previousFreq, txInfo, ...dataToSend } = dataHandler.dataToSend;
const { ps_errors, rt0_errors, rt1_errors, ims, eq, ant, st_forced, previousFreq, txInfo, rdsMode, ...dataToSend } = dataHandler.dataToSend;
res.json({
...dataToSend,
txInfo: txInfo,
ps_errors: ps_errors,
ant: ant
ant: ant,
rbds: serverConfig.webserver.rdsMode
});
});
const loginAttempts = {}; // Format: { 'ip': { count: 1, lastAttempt: 1234567890 } }
const MAX_ATTEMPTS = 25;
const MAX_ATTEMPTS = 15;
const WINDOW_MS = 15 * 60 * 1000;
const authenticate = (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
const now = Date.now();
if (!loginAttempts[ip]) {
loginAttempts[ip] = { count: 0, lastAttempt: now };
} else if (now - loginAttempts[ip].lastAttempt > WINDOW_MS) {
loginAttempts[ip] = { count: 0, lastAttempt: now };
}
if (!loginAttempts[ip]) loginAttempts[ip] = { count: 0, lastAttempt: now };
else if (now - loginAttempts[ip].lastAttempt > WINDOW_MS) loginAttempts[ip] = { count: 0, lastAttempt: now };
if (loginAttempts[ip].count >= MAX_ATTEMPTS) {
return res.status(403).json({
message: 'Too many login attempts. Please try again later.'
});
}
if (loginAttempts[ip].count >= MAX_ATTEMPTS) return res.status(403).json({message: 'Too many login attempts. Please try again later.'});
const { password } = req.body;
@@ -230,17 +244,16 @@ router.get('/logout', (req, res) => {
});
router.get('/kick', (req, res) => {
const ipAddress = req.query.ip; // Extract the IP address parameter from the query string
const ipAddress = req.query.ip;
// Terminate the WebSocket connection for the specified IP address
if(req.session.isAdminAuthenticated) {
helpers.kickClient(ipAddress);
}
if(req.session.isAdminAuthenticated) helpers.kickClient(ipAddress);
setTimeout(() => {
res.redirect('/setup');
}, 500);
});
router.get('/addToBanlist', (req, res) => {
if (!req.session.isAdminAuthenticated) return;
const ipAddress = req.query.ip;
const location = 'Unknown';
const date = Date.now();
@@ -248,32 +261,24 @@ router.get('/addToBanlist', (req, res) => {
userBanData = [ipAddress, location, date, reason];
if (typeof serverConfig.webserver.banlist !== 'object') {
serverConfig.webserver.banlist = [];
}
if (typeof serverConfig.webserver.banlist !== 'object') serverConfig.webserver.banlist = [];
if (req.session.isAdminAuthenticated) {
serverConfig.webserver.banlist.push(userBanData);
configSave();
res.json({ success: true, message: 'IP address added to banlist.' });
helpers.kickClient(ipAddress);
} else {
res.status(403).json({ success: false, message: 'Unauthorized access.' });
}
serverConfig.webserver.banlist.push(userBanData);
configSave();
res.json({ success: true, message: 'IP address added to banlist.' });
helpers.kickClient(ipAddress);
});
router.get('/removeFromBanlist', (req, res) => {
if (!req.session.isAdminAuthenticated) return;
const ipAddress = req.query.ip;
if (typeof serverConfig.webserver.banlist !== 'object') {
serverConfig.webserver.banlist = [];
}
if (typeof serverConfig.webserver.banlist !== 'object') serverConfig.webserver.banlist = [];
const banIndex = serverConfig.webserver.banlist.findIndex(ban => ban[0] === ipAddress);
if (banIndex === -1) {
return res.status(404).json({ success: false, message: 'IP address not found in banlist.' });
}
if (banIndex === -1) return res.status(404).json({ success: false, message: 'IP address not found in banlist.' });
serverConfig.webserver.banlist.splice(banIndex, 1);
configSave();
@@ -285,19 +290,14 @@ router.get('/removeFromBanlist', (req, res) => {
router.post('/saveData', (req, res) => {
const data = req.body;
let firstSetup;
if(req.session.isAdminAuthenticated || configExists() === false) {
if(req.session.isAdminAuthenticated || !configExists()) {
configUpdate(data);
fmdxList.update();
if(configExists() === false) {
firstSetup = true;
}
if(!configExists()) firstSetup = true;
logInfo('Server config changed successfully.');
if(firstSetup === true) {
res.status(200).send('Data saved successfully!\nPlease, restart the server to load your configuration.');
} else {
res.status(200).send('Data saved successfully!\nSome settings may need a server restart to apply.');
}
if(firstSetup === true) res.status(200).send('Data saved successfully!\nPlease, restart the server to load your configuration.');
else res.status(200).send('Data saved successfully!\nSome settings may need a server restart to apply.');
}
});
@@ -309,9 +309,8 @@ router.get('/getData', (req, res) => {
if(req.session.isAdminAuthenticated) {
// Check if the file exists
fs.access(configPath, fs.constants.F_OK, (err) => {
if (err) {
console.log(err);
} else {
if (err) console.log(err);
else {
// File exists, send it as the response
res.sendFile(path.join(__dirname, '../' + configName + '.json'));
}
@@ -324,9 +323,7 @@ router.get('/getDevices', (req, res) => {
parseAudioDevice((result) => {
res.json(result);
});
} else {
res.status(403).json({ error: 'Unauthorized' });
}
} else res.status(403).json({ error: 'Unauthorized' });
});
/* Static data are being sent through here on connection - these don't change when the server is running */
@@ -371,9 +368,7 @@ function canLog(id) {
}
}
if (logHistory[id] && (now - logHistory[id]) < sixtyMinutes) {
return false; // Deny logging if less than 60 minutes have passed
}
if (logHistory[id] && (now - logHistory[id]) < sixtyMinutes) return false; // Deny logging if less than 60 minutes have passed
logHistory[id] = now; // Update with the current timestamp
return true;
}
@@ -463,6 +458,7 @@ router.get('/tunnelservers', async (req, res) => {
{ value: "eu", host: "eu.fmtuner.org", label: "Europe" },
{ value: "us", host: "us.fmtuner.org", label: "Americas" },
{ value: "sg", host: "sg.fmtuner.org", label: "Asia & Oceania" },
{ value: "pldx", host: "pldx.fmtuner.org", label: "Poland (k201)" },
];
const results = await Promise.all(

View File

@@ -1,8 +1,7 @@
/* Libraries / Imports */
const fs = require('fs');
const fetch = require('node-fetch');
const { logDebug, logError, logInfo, logWarn } = require('./console');
const { serverConfig, configUpdate, configSave } = require('./server_config');
const { logDebug, logInfo, logWarn } = require('./console');
const { serverConfig, configSave } = require('./server_config');
var pjson = require('../package.json');
var os = require('os');
@@ -23,23 +22,15 @@ function send(request) {
fetch(url, options)
.then(response => response.json())
.then(data => {
if (data.success && data.token)
{
if (data.success && data.token) {
if (!serverConfig.identification.token)
{
logInfo("Registered to FM-DX Server Map successfully.");
serverConfig.identification.token = data.token;
configSave();
}
else
{
logDebug("FM-DX Server Map update successful.");
}
}
else
{
logWarn("Failed to update FM-DX Server Map: " + (data.error ? data.error : 'unknown error'));
}
else logDebug("FM-DX Server Map update successful.");
} else logWarn("Failed to update FM-DX Server Map: " + (data.error ? data.error : 'unknown error'));
})
.catch(error => {
logWarn("Failed to update FM-DX Server Map: " + error);
@@ -47,10 +38,7 @@ function send(request) {
}
function sendKeepalive() {
if (!serverConfig.identification.token)
{
return;
}
if (!serverConfig.identification.token) return;
const request = {
token: serverConfig.identification.token,
@@ -64,9 +52,7 @@ function sendUpdate() {
let currentOs = os.type() + ' ' + os.release();
let bwLimit = '';
if (serverConfig.webserver.tuningLimit === true) {
bwLimit = serverConfig.webserver.tuningLowerLimit + ' - ' + serverConfig.webserver.tuningUpperLimit + ' MHz';
}
if (serverConfig.webserver.tuningLimit === true) bwLimit = serverConfig.webserver.tuningLowerLimit + ' - ' + serverConfig.webserver.tuningUpperLimit + ' MHz';
const request = {
status: ((serverConfig.lockToAdmin == 'true' || serverConfig.publicTuner == 'false') ? 2 : 1),
@@ -79,40 +65,23 @@ function sendUpdate() {
tuner: serverConfig.device || '',
bwLimit: bwLimit,
os: currentOs,
version: pjson.version
version: pjson.version
};
if (serverConfig.identification.token)
{
request.token = serverConfig.identification.token;
}
if (serverConfig.identification.token) request.token = serverConfig.identification.token;
if (serverConfig.identification.proxyIp.length)
{
request.url = serverConfig.identification.proxyIp;
}
else
{
request.port = serverConfig.webserver.webserverPort;
}
if (serverConfig.identification.proxyIp.length) request.url = serverConfig.identification.proxyIp;
else request.port = serverConfig.webserver.webserverPort;
send(request);
}
function update() {
if (timeoutID !== null) {
clearTimeout(timeoutID);
}
if (!serverConfig.identification.broadcastTuner)
{
return;
}
if (timeoutID !== null) clearTimeout(timeoutID);
if (!serverConfig.identification.broadcastTuner) return;
sendUpdate();
timeoutID = setInterval(sendKeepalive, 5 * 60 * 1000);
}
module.exports = {
update
};
module.exports.update = update;

View File

@@ -1,3 +1,5 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const net = require('net');
@@ -5,43 +7,43 @@ const crypto = require('crypto');
const dataHandler = require('./datahandler');
const storage = require('./storage');
const consoleCmd = require('./console');
const { serverConfig, configExists, configSave } = require('./server_config');
const { serverConfig, configSave } = require('./server_config');
function parseMarkdown(parsed) {
parsed = parsed.replace(/<\/?[^>]+(>|$)/g, '');
var grayTextRegex = /--(.*?)--/g;
parsed = parsed.replace(grayTextRegex, '<span class="text-gray">$1</span>');
var boldRegex = /\*\*(.*?)\*\*/g;
parsed = parsed.replace(boldRegex, '<strong>$1</strong>');
var italicRegex = /\*(.*?)\*/g;
parsed = parsed.replace(italicRegex, '<em>$1</em>');
var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
parsed = parsed.replace(linkRegex, '<a href="$2" target="_blank">$1</a>');
parsed = parsed.replace(/\n/g, '<br>');
return parsed;
}
function removeMarkdown(parsed) {
parsed = parsed.replace(/<\/?[^>]+(>|$)/g, '');
var grayTextRegex = /--(.*?)--/g;
parsed = parsed.replace(grayTextRegex, '$1');
var boldRegex = /\*\*(.*?)\*\*/g;
parsed = parsed.replace(boldRegex, '$1');
var italicRegex = /\*(.*?)\*/g;
parsed = parsed.replace(italicRegex, '$1');
var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
parsed = parsed.replace(linkRegex, '$1');
return parsed;
}
@@ -93,9 +95,7 @@ let bannedASCache = { data: null, timestamp: 0 };
function fetchBannedAS(callback) {
const now = Date.now();
if (bannedASCache.data && now - bannedASCache.timestamp < 10 * 60 * 1000) {
return callback(null, bannedASCache.data);
}
if (bannedASCache.data && now - bannedASCache.timestamp < 10 * 60 * 1000) return callback(null, bannedASCache.data);
const req = https.get("https://fmdx.org/banned_as.json", { family: 4 }, (banResponse) => {
let banData = "";
@@ -137,9 +137,7 @@ function processConnection(clientIp, locationInfo, currentUsers, ws, callback) {
const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
fetchBannedAS((error, bannedAS) => {
if (error) {
console.error("Error fetching banned AS list:", error);
}
if (error) console.error("Error fetching banned AS list:", error);
if (bannedAS.some((as) => locationInfo.as?.includes(as))) {
const now = Date.now();
@@ -154,9 +152,7 @@ function processConnection(clientIp, locationInfo, currentUsers, ws, callback) {
}
const userLocation =
locationInfo.country === undefined
? "Unknown"
: `${locationInfo.city}, ${locationInfo.regionName}, ${locationInfo.countryCode}`;
locationInfo.country === undefined ? "Unknown" : `${locationInfo.city}, ${locationInfo.regionName}, ${locationInfo.countryCode}`;
storage.connectedUsers.push({
ip: clientIp,
@@ -165,9 +161,7 @@ function processConnection(clientIp, locationInfo, currentUsers, ws, callback) {
instance: ws,
});
consoleCmd.logInfo(
`Web client \x1b[32mconnected\x1b[0m (${normalizedClientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${userLocation}`
);
consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${normalizedClientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${userLocation}`);
callback("User allowed");
});
@@ -177,11 +171,11 @@ function formatUptime(uptimeInSeconds) {
const secondsInMinute = 60;
const secondsInHour = secondsInMinute * 60;
const secondsInDay = secondsInHour * 24;
const days = Math.floor(uptimeInSeconds / secondsInDay);
const hours = Math.floor((uptimeInSeconds % secondsInDay) / secondsInHour);
const minutes = Math.floor((uptimeInSeconds % secondsInHour) / secondsInMinute);
return `${days}d ${hours}h ${minutes}m`;
}
@@ -190,7 +184,7 @@ let incompleteDataBuffer = '';
function resolveDataBuffer(data, wss, rdsWss) {
var receivedData = incompleteDataBuffer + data.toString();
const isIncomplete = (receivedData.slice(-1) != '\n');
if (isIncomplete) {
const position = receivedData.lastIndexOf('\n');
if (position < 0) {
@@ -200,13 +194,9 @@ function resolveDataBuffer(data, wss, rdsWss) {
incompleteDataBuffer = receivedData.slice(position + 1);
receivedData = receivedData.slice(0, position + 1);
}
} else {
incompleteDataBuffer = '';
}
if (receivedData.length) {
dataHandler.handleData(wss, receivedData, rdsWss);
};
} else incompleteDataBuffer = '';
if (receivedData.length) dataHandler.handleData(wss, receivedData, rdsWss);
}
function kickClient(ipAddress) {
@@ -215,15 +205,13 @@ function kickClient(ipAddress) {
if (targetClient && targetClient.instance) {
// Send a termination message to the client
targetClient.instance.send('KICK');
// Close the WebSocket connection after a short delay to allow the client to receive the message
setTimeout(() => {
targetClient.instance.close();
consoleCmd.logInfo(`Web client kicked (${ipAddress})`);
}, 500);
} else {
consoleCmd.logInfo(`Kicking client ${ipAddress} failed. No suitable client found.`);
}
} else consoleCmd.logInfo(`Kicking client ${ipAddress} failed. No suitable client found.`);
}
function checkIPv6Support(callback) {
@@ -232,11 +220,8 @@ function checkIPv6Support(callback) {
server.listen(0, '::1', () => {
server.close(() => callback(true));
}).on('error', (error) => {
if (error.code === 'EADDRNOTAVAIL') {
callback(false);
} else {
callback(false);
}
if (error.code === 'EADDRNOTAVAIL') callback(false);
else callback(false);
});
}
@@ -265,27 +250,31 @@ function checkLatency(host, port = 80, timeout = 2000) {
});
}
function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, lengthCommands, endpointName) {
const command = message.toString();
function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, lengthCommands, endpointName, maxPayloadSize = 1024 * 1024) {
const rawCommand = message.toString();
const command = rawCommand.replace(/[\r\n]+/g, '');
const now = Date.now();
const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
if (endpointName === 'text') consoleCmd.logDebug(`Command received from \x1b[90m${clientIp}\x1b[0m: ${command}`);
// Initialize user command history if not present
if (!userCommandHistory[clientIp]) {
userCommandHistory[clientIp] = [];
if (command.length > maxPayloadSize) {
consoleCmd.logWarn(`Command from \x1b[90m${normalizedClientIp}\x1b[0m on \x1b[90m/${endpointName}\x1b[0m exceeded maximum payload size (${parseInt(command.length / 1024)} KB / ${parseInt(maxPayloadSize / 1024)} KB).`);
return "";
}
// Initialize user command history if not present
if (!userCommandHistory[clientIp]) userCommandHistory[clientIp] = [];
// Record the current timestamp for the user
userCommandHistory[clientIp].push(now);
// Remove timestamps older than 20 ms from the history
userCommandHistory[clientIp] = userCommandHistory[clientIp].filter(timestamp => now - timestamp <= 20);
// Check if there are 8 or more commands in the last 20 ms
if (userCommandHistory[clientIp].length >= 8) {
consoleCmd.logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming with rapid commands. Connection will be terminated and user will be banned.`);
consoleCmd.logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming with rapid commands. Connection will be terminated and user will be banned.`);
// Check if the normalized IP is already in the banlist
const isAlreadyBanned = serverConfig.webserver.banlist.some(banEntry => banEntry[0] === normalizedClientIp);
@@ -295,18 +284,16 @@ function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userC
consoleCmd.logInfo(`User \x1b[90m${normalizedClientIp}\x1b[0m has been added to the banlist due to extreme spam.`);
configSave();
}
ws.close(1008, 'Bot-like behavior detected');
return command; // Return command value before closing connection
ws.close(1008, 'Bot-like behavior detected');
return command; // Return command value before closing connection
}
// Update the last message time for general spam detection
lastMessageTime = now;
// Initialize command history for rate-limiting checks
if (!userCommands[command]) {
userCommands[command] = [];
}
if (!userCommands[command]) userCommands[command] = [];
// Record the current timestamp for this command
userCommands[command].push(now);
@@ -328,15 +315,45 @@ function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userC
}
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return unsafe.replace(/&/g, "&amp;")
.replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
};
// Start plugins with delay
function startPluginsWithDelay(plugins, delay) {
plugins.forEach((pluginPath, index) => {
setTimeout(() => {
const pluginName = path.basename(pluginPath, '.js'); // Extract plugin name from path
consoleCmd.logInfo(`-----------------------------------------------------------------`);
consoleCmd.logInfo(`Plugin ${pluginName} loaded successfully!`);
require(pluginPath);
}, delay * index);
});
// Add final log line after all plugins are loaded
setTimeout(() => {
consoleCmd.logInfo(`-----------------------------------------------------------------`);
}, delay * plugins.length);
}
// Function to find server files based on the plugins listed in config
function findServerFiles(plugins) {
let results = [];
plugins.forEach(plugin => {
// Remove .js extension if present
if (plugin.endsWith('.js')) plugin = plugin.slice(0, -3);
const pluginPath = path.join(__dirname, '..', 'plugins', `${plugin}_server.js`);
if (fs.existsSync(pluginPath) && fs.statSync(pluginPath).isFile()) results.push(pluginPath);
});
return results;
}
module.exports = {
authenticateWithXdrd, parseMarkdown, handleConnect, removeMarkdown, formatUptime, resolveDataBuffer, kickClient, checkIPv6Support, checkLatency, antispamProtection, escapeHtml
authenticateWithXdrd, parseMarkdown, handleConnect,
removeMarkdown, formatUptime, resolveDataBuffer,
kickClient, checkIPv6Support, checkLatency,
antispamProtection, escapeHtml, findServerFiles,
startPluginsWithDelay
}

View File

@@ -1,4 +1,3 @@
// Library imports
const express = require('express');
const endpoints = require('./endpoints');
const session = require('express-session');
@@ -8,60 +7,28 @@ const readline = require('readline');
const app = express();
const httpServer = http.createServer(app);
const WebSocket = require('ws');
const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
const chatWss = new WebSocket.Server({ noServer: true });
const rdsWss = new WebSocket.Server({ noServer: true });
const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
const fs = require('fs');
const path = require('path');
const net = require('net');
const client = new net.Socket();
const { SerialPort } = require('serialport');
const audioServer = require('./stream/3las.server');
const tunnel = require('./tunnel');
const { createChatServer } = require('./chat');
const figlet = require('figlet');
// File imports
const helpers = require('./helpers');
const { findServerFiles, startPluginsWithDelay } = helpers;
const dataHandler = require('./datahandler');
const fmdxList = require('./fmdx_list');
const { logDebug, logError, logInfo, logWarn, logChat } = require('./console');
const { logError, logInfo, logWarn } = require('./console');
const storage = require('./storage');
const { serverConfig, configExists, configSave } = require('./server_config');
const { serverConfig, configExists } = require('./server_config');
const pluginsApi = require('./plugins_api');
const pjson = require('../package.json');
// Function to find server files based on the plugins listed in config
function findServerFiles(plugins) {
let results = [];
plugins.forEach(plugin => {
// Remove .js extension if present
if (plugin.endsWith('.js')) {
plugin = plugin.slice(0, -3);
}
const pluginPath = path.join(__dirname, '..', 'plugins', `${plugin}_server.js`);
if (fs.existsSync(pluginPath) && fs.statSync(pluginPath).isFile()) {
results.push(pluginPath);
}
});
return results;
}
// Start plugins with delay
function startPluginsWithDelay(plugins, delay) {
plugins.forEach((pluginPath, index) => {
setTimeout(() => {
const pluginName = path.basename(pluginPath, '.js'); // Extract plugin name from path
logInfo(`-----------------------------------------------------------------`);
logInfo(`Plugin ${pluginName} loaded successfully!`);
require(pluginPath);
}, delay * index);
});
// Add final log line after all plugins are loaded
setTimeout(() => {
logInfo(`-----------------------------------------------------------------`);
}, delay * plugins.length);
}
const client = new net.Socket();
const wss = new WebSocket.Server({ noServer: true });
const rdsWss = new WebSocket.Server({ noServer: true });
const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
// Get all plugins from config and find corresponding server files
const plugins = findServerFiles(serverConfig.plugins);
@@ -78,19 +45,12 @@ const terminalWidth = readline.createInterface({
output: process.stdout
}).output.columns;
console.log(`\x1b[32m
_____ __ __ ______ __ __ __ _
| ___| \\/ | | _ \\ \\/ / \\ \\ / /__| |__ ___ ___ _ ____ _____ _ __
| |_ | |\\/| |_____| | | \\ / \\ \\ /\\ / / _ \\ '_ \\/ __|/ _ \\ '__\\ \\ / / _ \\ '__|
| _| | | | |_____| |_| / \\ \\ V V / __/ |_) \\__ \\ __/ | \\ V / __/ |
|_| |_| |_| |____/_/\\_\\ \\_/\\_/ \\___|_.__/|___/\\___|_| \\_/ \\___|_|
`);
console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org\x1b[0m');
console.log('\x1b[32m' + figlet.textSync("FM-DX Webserver"));
console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org + KubaPro010\x1b[0m');
console.log("v" + pjson.version)
console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
// Start ffmpeg
const audioWss = require('./stream/ws.js');
require('./stream/index');
require('./plugins');
@@ -100,12 +60,13 @@ let timeoutAntenna;
app.use(bodyParser.urlencoded({ extended: true }));
const sessionMiddleware = session({
secret: 'GTce3tN6U8odMwoI',
secret: 'GTce3tN6U8odMwoI', // Cool
resave: false,
saveUninitialized: true,
});
app.use(sessionMiddleware);
app.use(bodyParser.json());
const chatWss = createChatServer(storage);
connectToXdrd();
connectToSerial();
@@ -122,21 +83,17 @@ setInterval(() => {
logWarn('Communication lost from ' + serverConfig.xdrd.comPort + ', force closing serialport.');
setTimeout(() => {
serialport.close((err) => {
if (err) {
logError('Error closing serialport: ', err.message);
}
if (err) logError('Error closing serialport: ', err.message);
});
}, 1000);
} else {
logWarn('Communication lost from ' + serverConfig.xdrd.comPort + '.');
}
} else logWarn('Communication lost from ' + serverConfig.xdrd.comPort + '.');
}
}, 2000);
// Serial Connection
function connectToSerial() {
if (serverConfig.xdrd.wirelessConnection === false) {
if (serverConfig.xdrd.wirelessConnection === true) return;
// Configure the SerialPort with DTR and RTS options
serialport = new SerialPort({
path: serverConfig.xdrd.comPort,
@@ -155,13 +112,14 @@ if (serverConfig.xdrd.wirelessConnection === false) {
}, 5000);
return;
}
logInfo('Using COM device: ' + serverConfig.xdrd.comPort);
logInfo('Using serial port: ' + serverConfig.xdrd.comPort);
dataHandler.state.isSerialportAlive = true;
pluginsApi.setOutput(serialport);
setTimeout(() => {
serialport.write('x\n');
}, 3000);
setTimeout(() => {
serialport.write('Q0\n');
serialport.write('M0\n');
@@ -173,34 +131,25 @@ if (serverConfig.xdrd.wirelessConnection === false) {
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
} else if (dataHandler.state.lastFrequencyAlive && dataHandler.state.isSerialportRetrying) { // Serialport retry code when port is open but communication is lost
serialport.write('T' + (dataHandler.state.lastFrequencyAlive * 1000) + '\n');
} else {
serialport.write('T87500\n');
}
} else serialport.write('T87500\n');
dataHandler.state.isSerialportRetrying = false;
serialport.write('A0\n');
if (serverConfig.device === 'si47xx') serialport.write('A0\n');
serialport.write('F-1\n');
serialport.write('W0\n');
serverConfig.webserver.rdsMode ? serialport.write('D1\n') : serialport.write('D0\n');
// cEQ and iMS combinations
if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "0") {
serialport.write("G00\n"); // Both Disabled
} else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "0") {
serialport.write(`G10\n`);
} else if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "1") {
serialport.write(`G01\n`);
} else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "1") {
serialport.write("G11\n"); // Both Enabled
}
if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "0") serialport.write("G00\n"); // Both Disabled
else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "0") serialport.write(`G10\n`);
else if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "1") serialport.write(`G01\n`);
else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "1") serialport.write("G11\n"); // Both Enabled
// Handle stereo mode
if (serverConfig.stereoStartup === "1") {
serialport.write("B1\n"); // Mono
}
serverConfig.audio.startupVolume
? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n')
if (serverConfig.stereoStartup === "1") serialport.write("B1\n"); // Mono
serverConfig.audio.startupVolume
? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n')
: serialport.write('Y100\n');
}, 6000);
serialport.on('data', (data) => {
helpers.resolveDataBuffer(data, wss, rdsWss);
});
@@ -212,6 +161,7 @@ if (serverConfig.xdrd.wirelessConnection === false) {
// Handle port closure
serialport.on('close', () => {
pluginsApi.setOutput(null);
logWarn('Disconnected from ' + serverConfig.xdrd.comPort + '. Attempting to reconnect.');
setTimeout(() => {
dataHandler.state.isSerialportRetrying = true;
@@ -220,7 +170,6 @@ if (serverConfig.xdrd.wirelessConnection === false) {
});
return serialport;
}
}
// xdrd connection
let authFlags = {};
@@ -231,7 +180,8 @@ function connectToXdrd() {
if (xdrd.wirelessConnection && configExists()) {
client.connect(xdrd.xdrdPort, xdrd.xdrdIp, () => {
logInfo('Connection to xdrd established successfully.');
pluginsApi.setOutput(client);
authFlags = {
authMsg: false,
firstClient: false,
@@ -245,11 +195,9 @@ function connectToXdrd() {
client.on('data', (data) => {
const { xdrd } = serverConfig;
helpers.resolveDataBuffer(data, wss, rdsWss);
if (authFlags.authMsg == true && authFlags.messageCount > 1) {
return;
}
if (authFlags.authMsg == true && authFlags.messageCount > 1) return;
authFlags.messageCount++;
const receivedData = data.toString();
@@ -264,9 +212,8 @@ client.on('data', (data) => {
if (line.startsWith('a')) {
authFlags.authMsg = true;
logWarn('Authentication with xdrd failed. Is your password set correctly?');
} else if (line.startsWith('o1,')) {
authFlags.firstClient = true;
} else if (line.startsWith('T') && line.length <= 7) {
} else if (line.startsWith('o1,')) authFlags.firstClient = true;
else if (line.startsWith('T') && line.length <= 7) {
const freq = line.slice(1) / 1000;
dataHandler.dataToSend.freq = freq.toFixed(3);
} else if (line.startsWith('OK')) {
@@ -289,7 +236,7 @@ client.on('data', (data) => {
client.write(serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? 'T' + Math.round(serverConfig.defaultFreq * 1000) + '\n' : 'T87500\n');
dataHandler.initialData.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3);
dataHandler.dataToSend.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3);
client.write('A0\n');
if (serverConfig.device === 'si47xx') serialport.write('A0\n');
client.write(serverConfig.audio.startupVolume ? 'Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n' : 'Y100\n');
serverConfig.webserver.rdsMode ? client.write('D1\n') : client.write('D0\n');
return;
@@ -299,14 +246,13 @@ client.on('data', (data) => {
});
client.on('close', () => {
pluginsApi.setOutput(null);
if(serverConfig.autoShutdown === false) {
logWarn('Disconnected from xdrd. Attempting to reconnect.');
setTimeout(function () {
connectToXdrd();
}, 2000)
} else {
logWarn('Disconnected from xdrd.');
}
} else logWarn('Disconnected from xdrd.');
});
client.on('error', (err) => {
@@ -333,9 +279,6 @@ app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../web'));
app.use('/', endpoints);
/**
* WEBSOCKET BLOCK
*/
const tunerLockTracker = new WeakMap();
const ipConnectionCounts = new Map(); // Per-IP limit variables
const ipLogTimestamps = new Map();
@@ -358,33 +301,24 @@ setInterval(() => {
wss.on('connection', (ws, request) => {
const output = serverConfig.xdrd.wirelessConnection ? client : serialport;
let clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
let clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
const userCommandHistory = {};
const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
if (clientIp && serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
if (clientIp && clientIp.includes(',')) {
clientIp = clientIp.split(',')[0].trim();
}
if (clientIp && clientIp.includes(',')) clientIp = clientIp.split(',')[0].trim();
// Per-IP limit connection open
if (clientIp) {
const isLocalIp = (
clientIp === '127.0.0.1' ||
clientIp === '::1' ||
clientIp === '::ffff:127.0.0.1' ||
clientIp.startsWith('192.168.') ||
clientIp.startsWith('10.') ||
clientIp.startsWith('172.16.')
);
clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === '::ffff:127.0.0.1' ||
clientIp.startsWith('192.168.') || clientIp.startsWith('10.') || clientIp.startsWith('172.16.'));
if (!isLocalIp) {
if (!ipConnectionCounts.has(clientIp)) {
ipConnectionCounts.set(clientIp, 0);
}
if (!ipConnectionCounts.has(clientIp)) ipConnectionCounts.set(clientIp, 0);
const currentCount = ipConnectionCounts.get(clientIp);
if (currentCount >= MAX_CONNECTIONS_PER_IP) {
ws.close(1008, 'Too many open connections from this IP');
@@ -400,7 +334,9 @@ wss.on('connection', (ws, request) => {
}
}
if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
if (clientIp !== '::ffff:127.0.0.1' ||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
(request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
currentUsers++;
}
@@ -411,51 +347,46 @@ wss.on('connection', (ws, request) => {
ws.close(1008, 'Banned IP');
return;
}
dataHandler.showOnlineUsers(currentUsers);
dataHandler.showOnlineUsers(currentUsers);
if (currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) {
serverConfig.xdrd.wirelessConnection ? connectToXdrd() : serialport.write('x\n');
}
});
if (currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) serverConfig.xdrd.wirelessConnection ? connectToXdrd() : serialport.write('x\n');
});
const userCommands = {};
let lastWarn = { time: 0 };
ws.on('message', (message) => {
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text');
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text', 16 * 1024);
if (!clientIp.includes("127.0.0.1")) {
if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) ||
if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) ||
((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) {
logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command: ${command.slice(0, 64)}.`);
return;
}
}
if (command.includes("\'")) {
return;
}
if (command.includes("\'")) return;
const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {};
if (command.startsWith('w') && (isAdminAuthenticated || isTuneAuthenticated)) {
switch (command) {
case 'wL1':
if (isAdminAuthenticated) serverConfig.lockToAdmin = true;
case 'wL1':
if (isAdminAuthenticated) serverConfig.lockToAdmin = true;
break;
case 'wL0':
if (isAdminAuthenticated) serverConfig.lockToAdmin = false;
case 'wL0':
if (isAdminAuthenticated) serverConfig.lockToAdmin = false;
break;
case 'wT0':
serverConfig.publicTuner = true;
if(!isAdminAuthenticated) tunerLockTracker.delete(ws);
case 'wT0':
serverConfig.publicTuner = true;
if(!isAdminAuthenticated) tunerLockTracker.delete(ws);
break;
case 'wT1':
case 'wT1':
serverConfig.publicTuner = false;
if(!isAdminAuthenticated) tunerLockTracker.set(ws, true);
break;
default:
default:
break;
}
}
@@ -463,15 +394,11 @@ wss.on('connection', (ws, request) => {
if (command.startsWith('T')) {
const tuneFreq = Number(command.slice(1)) / 1000;
const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver;
if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) {
return;
}
if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) return;
}
if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) {
output.write(`${command}\n`);
}
if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) output.write(`${command}\n`);
});
ws.on('close', (code, reason) => {
@@ -482,7 +409,7 @@ wss.on('connection', (ws, request) => {
clientIp === '::1' ||
clientIp === '::ffff:127.0.0.1' ||
clientIp.startsWith('192.168.') ||
clientIp.startsWith('10.') ||
clientIp.startsWith('10.') ||
clientIp.startsWith('172.16.')
);
if (!isLocalIp) {
@@ -491,176 +418,75 @@ wss.on('connection', (ws, request) => {
}
}
if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
if (clientIp !== '::ffff:127.0.0.1' ||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
(request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
currentUsers--;
}
dataHandler.showOnlineUsers(currentUsers);
dataHandler.showOnlineUsers(currentUsers);
const index = storage.connectedUsers.findIndex(user => user.ip === clientIp);
if (index !== -1) {
storage.connectedUsers.splice(index, 1);
}
const index = storage.connectedUsers.findIndex(user => user.ip === clientIp);
if (index !== -1) storage.connectedUsers.splice(index, 1);
if (currentUsers === 0) {
storage.connectedUsers = [];
if (currentUsers === 0) {
storage.connectedUsers = [];
if (serverConfig.bwAutoNoUsers === "1") {
output.write("W0\n"); // Auto BW 'Enabled'
}
if (serverConfig.bwAutoNoUsers === "1") output.write("W0\n"); // Auto BW 'Enabled'
// cEQ and iMS combinations
if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") {
output.write("G00\n"); // Both Disabled
} else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") {
output.write(`G0${dataHandler.dataToSend.ims}\n`);
} else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") {
output.write(`G${dataHandler.dataToSend.eq}0\n`);
} else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") {
output.write(`G1${dataHandler.dataToSend.ims}\n`);
} else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") {
output.write(`G${dataHandler.dataToSend.eq}1\n`);
} else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") {
output.write("G10\n"); // Only cEQ enabled
} else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") {
output.write("G01\n"); // Only iMS enabled
} else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") {
output.write("G11\n"); // Both Enabled
}
// cEQ and iMS combinations
if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") output.write("G00\n"); // Both Disabled
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") output.write(`G0${dataHandler.dataToSend.ims}\n`);
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") output.write(`G${dataHandler.dataToSend.eq}0\n`);
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") output.write(`G1${dataHandler.dataToSend.ims}\n`);
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") output.write(`G${dataHandler.dataToSend.eq}1\n`);
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") output.write("G10\n"); // Only cEQ enabled
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") output.write("G01\n"); // Only iMS enabled
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") output.write("G11\n"); // Both Enabled
// Handle stereo mode
if (serverConfig.stereoNoUsers === "1") {
output.write("B0\n");
} else if (serverConfig.stereoNoUsers === "2") {
output.write("B1\n");
}
// Handle stereo mode
if (serverConfig.stereoNoUsers === "1") output.write("B0\n");
else if (serverConfig.stereoNoUsers === "2") output.write("B1\n");
// Handle Antenna selection
if (timeoutAntenna) clearTimeout(timeoutAntenna);
timeoutAntenna = setTimeout(() => {
if (serverConfig.antennaNoUsers === "1") {
output.write("Z0\n");
} else if (serverConfig.antennaNoUsers === "2") {
output.write("Z1\n");
} else if (serverConfig.antennaNoUsers === "3") {
output.write("Z2\n");
} else if (serverConfig.antennaNoUsers === "4") {
output.write("Z3\n");
}
}, serverConfig.antennaNoUsersDelay ? 15000 : 0);
}
// Handle Antenna selection
if (timeoutAntenna) clearTimeout(timeoutAntenna);
timeoutAntenna = setTimeout(() => {
if (serverConfig.antennaNoUsers === "1") output.write("Z0\n");
else if (serverConfig.antennaNoUsers === "2") output.write("Z1\n");
else if (serverConfig.antennaNoUsers === "3") output.write("Z2\n");
else if (serverConfig.antennaNoUsers === "4") output.write("Z3\n");
}, serverConfig.antennaNoUsersDelay ? 15000 : 0);
}
if (tunerLockTracker.has(ws)) {
logInfo(`User who locked the tuner left. Unlocking the tuner.`);
output.write('wT0\n')
tunerLockTracker.delete(ws);
serverConfig.publicTuner = true;
}
if (tunerLockTracker.has(ws)) {
logInfo(`User who locked the tuner left. Unlocking the tuner.`);
output.write('wT0\n')
tunerLockTracker.delete(ws);
serverConfig.publicTuner = true;
}
if (currentUsers === 0 && serverConfig.enableDefaultFreq === true &&
serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) {
setTimeout(function() {
if (currentUsers === 0) {
output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
dataHandler.resetToDefault(dataHandler.dataToSend);
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
}
}, 10000);
}
if (currentUsers === 0 && serverConfig.enableDefaultFreq === true &&
serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) {
setTimeout(function() {
if (currentUsers === 0) {
output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
dataHandler.resetToDefault(dataHandler.dataToSend);
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
}
}, 10000);
}
if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection === true) {
client.write('X\n');
}
if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection === true) client.write('X\n');
if (code !== 1008) {
logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${normalizedClientIp}) \x1b[90m[${currentUsers}]`);
}
if (code !== 1008) logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${normalizedClientIp}) \x1b[90m[${currentUsers}]`);
});
ws.on('error', console.error);
});
// CHAT WEBSOCKET BLOCK
chatWss.on('connection', (ws, request) => {
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
// Send chat history to the newly connected client
storage.chatHistory.forEach(function(message) {
message.history = true;
!request.session.isAdminAuthenticated ? delete message.ip : null;
ws.send(JSON.stringify(message));
});
const ipMessage = {
type: 'clientIp',
ip: clientIp,
admin: request.session.isAdminAuthenticated
};
ws.send(JSON.stringify(ipMessage));
// Anti-spam tracking for each client
const userCommands = {};
let lastWarn = { time: 0 };
ws.on('message', function incoming(message) {
// Anti-spam
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'chat');
let messageData;
try {
messageData = JSON.parse(message);
} catch (error) {
ws.send(JSON.stringify({ error: "Invalid message format" }));
return;
}
// Escape nickname and other potentially unsafe fields
if (messageData.nickname) {
messageData.nickname = helpers.escapeHtml(messageData.nickname);
}
messageData.ip = clientIp;
const currentTime = new Date();
const hours = String(currentTime.getHours()).padStart(2, '0');
const minutes = String(currentTime.getMinutes()).padStart(2, '0');
messageData.time = `${hours}:${minutes}`; // Adding current time to the message object in hours:minutes format
if (serverConfig.webserver.banlist?.includes(clientIp)) { return; }
if (request.session.isAdminAuthenticated === true) { messageData.admin = true; }
if (messageData.message.length > 255) { messageData.message = messageData.message.substring(0, 255); }
storage.chatHistory.push(messageData);
if (storage.chatHistory.length > 50) { storage.chatHistory.shift(); }
logChat(messageData);
chatWss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
// Only include IP for admin clients
let responseMessage = { ...messageData };
if (request.session.isAdminAuthenticated !== true) {
delete responseMessage.ip;
}
const modifiedMessage = JSON.stringify(responseMessage);
client.send(modifiedMessage);
}
});
});
ws.on('close', function close() {});
});
// Additional web socket for using plugins
pluginsWss.on('connection', (ws, request) => {
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
pluginsWss.on('connection', (ws, request) => {
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
@@ -676,7 +502,7 @@ pluginsWss.on('connection', (ws, request) => {
let messageData;
try {
try { // JS Requires the try statement to have braces, unlike the if statement. This extends the huge list of proofs that this is a fucking toy language
messageData = JSON.parse(message); // Attempt to parse the JSON
} catch (error) {
// console.error("Failed to parse message:", error); // Log the error
@@ -687,96 +513,37 @@ pluginsWss.on('connection', (ws, request) => {
// Broadcast the message to all other clients
pluginsWss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(modifiedMessage); // Send the message to all clients
}
if (client.readyState === WebSocket.OPEN) client.send(modifiedMessage); // Send the message to all clients
});
});
ws.on('close', () => {
// logInfo('WebSocket Extra connection closed'); // Use custom logInfo function
});
ws.on('error', error => {
logError('WebSocket Extra error: ' + error); // Use custom logError function
});
});
function isPortOpen(host, port, timeout = 1000) {
return new Promise((resolve) => {
const socket = new net.Socket();
const onError = () => {
socket.destroy();
resolve(false);
};
socket.setTimeout(timeout);
socket.once('error', onError);
socket.once('timeout', onError);
socket.connect(port, host, () => {
socket.end();
resolve(true);
});
});
}
// Websocket register for /text, /audio and /chat paths
httpServer.on('upgrade', (request, socket, head) => {
if (request.url === '/text') {
sessionMiddleware(request, {}, () => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
} else if (request.url === '/audio') {
if (typeof audioServer?.handleAudioUpgrade === 'function') {
audioServer.handleAudioUpgrade(request, socket, head, (ws) => {
audioServer.Server?.Server?.emit?.('connection', ws, request);
});
} else {
logWarn('[Audio WebSocket] Audio server not ready — dropping client connection.');
socket.destroy();
}
} else if (request.url === '/chat') {
sessionMiddleware(request, {}, () => {
chatWss.handleUpgrade(request, socket, head, (ws) => {
chatWss.emit('connection', ws, request);
});
});
} else if (request.url === '/rds' || request.url === '/rdsspy') {
sessionMiddleware(request, {}, () => {
rdsWss.handleUpgrade(request, socket, head, (ws) => {
rdsWss.emit('connection', ws, request);
const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress;
const userCommandHistory = {};
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
// Anti-spam tracking for each client
const userCommands = {};
let lastWarn = { time: 0 };
ws.on('message', function incoming(message) {
// Anti-spam
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'rds');
});
});
});
} else if (request.url === '/data_plugins') {
sessionMiddleware(request, {}, () => {
pluginsWss.handleUpgrade(request, socket, head, (ws) => {
pluginsWss.emit('connection', ws, request);
});
});
} else {
// Websocket register for /text, /audio and /chat paths
httpServer.on('upgrade', (request, socket, head) => {
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
if (serverConfig.webserver.banlist?.includes(clientIp)) {
socket.destroy();
return;
}
var upgradeWss = undefined;
if (request.url === '/text') upgradeWss = wss;
else if (request.url === '/audio') upgradeWss = audioWss;
else if (request.url === '/chat' && serverConfig.webserver.chatEnabled === true) upgradeWss = chatWss;
else if (request.url === '/rds' || request.url === '/rdsspy') upgradeWss = rdsWss;
else if (request.url === '/data_plugins') upgradeWss = pluginsWss;
if(upgradeWss) {
sessionMiddleware(request, {}, () => {
upgradeWss.handleUpgrade(request, socket, head, (ws) => {
upgradeWss.emit('connection', ws, request);
});
});
} else socket.destroy();
});
app.use(express.static(path.join(__dirname, '../web'))); // Serve the entire web folder to the user
@@ -794,18 +561,16 @@ helpers.checkIPv6Support((isIPv6Supported) => {
const startServer = (address, isIPv6) => {
httpServer.listen(port, address, () => {
if (!isIPv6 && !configExists()) {
logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${port}\x1b[0m to continue with setup.`);
} else {
logServerStart(address, isIPv6);
}
if (!isIPv6 && !configExists()) logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${port}\x1b[0m to continue with setup.`);
else logServerStart(address, isIPv6);
});
};
if (isIPv6Supported) {
startServer(ipv4Address, false); // Start on IPv4
startServer(ipv6Address, true); // Start on IPv6
} else {
startServer(ipv4Address, false); // Start only on IPv4
}
} else startServer(ipv4Address, false); // Start only on IPv4
});
pluginsApi.registerServerContext({ wss, pluginsWss, httpServer, serverConfig });
module.exports = { wss, pluginsWss, httpServer, serverConfig };

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,6 @@
const fs = require('fs');
const path = require('path');
const consoleCmd = require('./console');
const { serverConfig } = require('./server_config');
// Function to read all .js files in a directory
function readJSFiles(dir) {
@@ -11,7 +10,6 @@ function readJSFiles(dir) {
// Function to parse plugin config from a file
function parsePluginConfig(filePath) {
const fileContent = fs.readFileSync(filePath, 'utf8');
const pluginConfig = {};
// Assuming pluginConfig is a JavaScript object defined in each .js file
@@ -31,9 +29,7 @@ function parsePluginConfig(filePath) {
}
// Check if the destination directory exists, if not, create it
if (!fs.existsSync(destinationDir)) {
fs.mkdirSync(destinationDir, { recursive: true }); // Create directory recursively
}
if (!fs.existsSync(destinationDir)) fs.mkdirSync(destinationDir, { recursive: true }); // Create directory recursively
const destinationFile = path.join(destinationDir, path.basename(sourcePath));
@@ -41,9 +37,7 @@ function parsePluginConfig(filePath) {
if (process.platform !== 'win32') {
// On Linux, create a symlink
try {
if (fs.existsSync(destinationFile)) {
fs.unlinkSync(destinationFile); // Remove existing file/symlink
}
if (fs.existsSync(destinationFile)) fs.unlinkSync(destinationFile); // Remove existing file/symlink
fs.symlinkSync(sourcePath, destinationFile);
setTimeout(function() {
consoleCmd.logInfo(`Plugin ${pluginConfig.name} ${pluginConfig.version} initialized successfully.`);
@@ -52,9 +46,7 @@ function parsePluginConfig(filePath) {
console.error(`Error creating symlink at ${destinationFile}: ${err.message}`);
}
}
} else {
console.error(`Error: frontEndPath is not defined in ${filePath}`);
}
} else console.error(`Error: frontEndPath is not defined in ${filePath}`);
} catch (err) {
console.error(`Error parsing plugin config from ${filePath}: ${err.message}`);
}
@@ -71,9 +63,7 @@ function collectPluginConfigs() {
jsFiles.forEach(file => {
const filePath = path.join(pluginsDir, file);
const config = parsePluginConfig(filePath);
if (Object.keys(config).length > 0) {
pluginConfigs.push(config);
}
if (Object.keys(config).length > 0) pluginConfigs.push(config);
});
return pluginConfigs;
@@ -81,9 +71,7 @@ function collectPluginConfigs() {
// Ensure the web/js/plugins directory exists
const webJsPluginsDir = path.join(__dirname, '../web/js/plugins');
if (!fs.existsSync(webJsPluginsDir)) {
fs.mkdirSync(webJsPluginsDir, { recursive: true });
}
if (!fs.existsSync(webJsPluginsDir)) fs.mkdirSync(webJsPluginsDir, { recursive: true });
// Main function to create symlinks/junctions for plugins
function createLinks() {
@@ -93,13 +81,8 @@ function createLinks() {
if (process.platform === 'win32') {
// On Windows, create a junction
try {
if (fs.existsSync(destinationPluginsDir)) {
fs.rmSync(destinationPluginsDir, { recursive: true });
}
if (fs.existsSync(destinationPluginsDir)) fs.rmSync(destinationPluginsDir, { recursive: true });
fs.symlinkSync(pluginsDir, destinationPluginsDir, 'junction');
setTimeout(function() {
//consoleCmd.logInfo(`Plugin ${pluginConfig.name} ${pluginConfig.version} initialized successfully.`);
}, 500)
} catch (err) {
console.error(`Error creating junction at ${destinationPluginsDir}: ${err.message}`);
}
@@ -110,6 +93,4 @@ function createLinks() {
const allPluginConfigs = collectPluginConfigs();
createLinks();
module.exports = {
allPluginConfigs
};
module.exports = allPluginConfigs;

137
server/plugins_api.js Normal file
View File

@@ -0,0 +1,137 @@
// plugins_api.js
// Shared API for server plugins:
// - Provides privileged/admin command access
// - Exposes server-side hooks for inter-plugin communication
// - Optionally broadcasts events to connected plugin WebSocket clients
const { EventEmitter } = require('events');
const { logWarn, logError } = require('./console');
let output = null;
let wss = null;
let pluginsWss = null;
let httpServer = null;
let serverConfig = null;
// ---- internal plugin event bus ----
const pluginEvents = new EventEmitter();
// prevent accidental memory leak warnings
pluginEvents.setMaxListeners(50);
// ---- registration server side ----
function registerServerContext(ctx) {
if (ctx.wss) wss = ctx.wss;
if (ctx.pluginsWss) pluginsWss = ctx.pluginsWss;
if (ctx.httpServer) httpServer = ctx.httpServer;
if (ctx.serverConfig) serverConfig = ctx.serverConfig;
}
function setOutput(newOutput) {
output = newOutput;
}
function clearOutput() {
output = null;
}
// ---- accessors plugin side ----
function getWss() {
return wss;
}
function getPluginsWss() {
return pluginsWss;
}
function getHttpServer() {
return httpServer;
}
function getServerConfig() {
return serverConfig;
}
// ---- privileged command path ----
async function sendPrivilegedCommand(command, isPluginInternal = false) {
const maxWait = 10000;
const interval = 500;
let waited = 0;
while (!output && waited < maxWait) {
await new Promise(resolve => setTimeout(resolve, interval));
waited += interval;
}
if (!output) {
logError(`[Privileged Send] Timeout waiting for output (${command})`);
return false;
}
if (isPluginInternal) {
output.write(`${command}\n`);
//logInfo(`[Privileged Plugin] Command sent: ${command}`); // Debug
return true;
}
logWarn(`[Privileged Send] Rejected (not internal): ${command.slice(0, 64)}`);
return false;
}
// ---- plugin hook API ----
function emitPluginEvent(event, payload, opts = {}) {
pluginEvents.emit(event, payload);
// Stop here unless option to broadcast to clients if true
if (opts.broadcast === false) return;
// Broadcast to connected plugin WebSocket clients if available
if (pluginsWss) {
const message = JSON.stringify({ type: event, value: payload });
pluginsWss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
try {
// Send event to client
client.send(message);
} catch (err) {
logWarn(`[plugins_api] Failed to send ${event} to client: ${err.message}`);
}
}
});
}
}
function onPluginEvent(event, handler) {
pluginEvents.on(event, handler);
}
function offPluginEvent(event, handler) {
pluginEvents.off(event, handler);
}
// ---- exports ----
module.exports = {
// server registration
registerServerContext,
setOutput,
clearOutput,
// server context access
getWss,
getPluginsWss,
getHttpServer,
getServerConfig,
// privileged control
sendPrivilegedCommand,
// inter-plugin hooks
emitPluginEvent,
onPluginEvent,
offPluginEvent
};

185
server/rds.js Normal file
View File

@@ -0,0 +1,185 @@
const { rdsEccLookup, iso, countries } = require("./rds_country.js")
class RDSDecoder {
constructor(data) {
this.data = data;
this.clear()
}
clear() {
this.data.pi = '?';
this.ps = Array(8).fill(' ');
this.ps_errors = Array(8).fill("0");
this.rt0 = Array(64).fill(' ');
this.rt0_errors = Array(64).fill("0");
this.rt1 = Array(64).fill(' ');
this.rt1_errors = Array(64).fill("0");
this.data.ps = '';
this.data.rt1 = '';
this.data.rt0 = '';
this.data.pty = 0;
this.data.tp = 0;
this.data.ta = 0;
this.data.ms = -1;
this.data.rt_flag = 0;
this.rt1_to_clear = false;
this.rt0_to_clear = false;
this.data.ecc = null;
this.data.country_name = ""
this.data.country_iso = "UN"
this.af_len = 0;
this.data.af = []
this.af_am_follows = false;
this.last_pi_error = 0;
}
decodeGroup(blockA, blockB, blockC, blockD, error) {
const a_error = (error >> 6) & 3;
const b_error = (error >> 4) & 3;
const c_error = (error >> 2) & 3;
const d_error = error & 3;
if(this.last_pi_error > a_error) {
this.data.pi = blockA.toString(16).toUpperCase().padStart(4, '0');
this.last_pi_error = a_error;
}
if(b_error !== 0) return; // B chooses what group this is, if this has errors, we are screwed
const group = (blockB >> 12) & 0xF;
const version = (blockB >> 11) & 0x1;
this.data.tp = Number((blockB >> 10) & 1);
this.data.pty = (blockB >> 5) & 31;
if (group === 0) {
this.data.ta = (blockB >> 4) & 1;
this.data.ms = (blockB >> 3) & 1;
if(version === 0 && c_error !== 3) {
var af_high = blockC >> 8;
var af_low = blockC & 0xFF;
var BASE = 224;
var FILLER = 205;
var AM_FOLLOWS = 250;
if(af_high >= BASE && af_high <= (BASE+25)) {
this.af_len = af_high-BASE;
if(this.af_len !== this.data.af.length) {
this.data.af = [];
this.af_am_follows = false;
if(af_low != FILLER && af_low != AM_FOLLOWS) this.data.af.push((af_low+875)*100)
else if(af_low == AM_FOLLOWS) this.af_am_follows = true;
}
} else if(this.data.af.length != this.af_len) {
if(!(af_high == AM_FOLLOWS || this.af_am_follows)) {
var freq = (af_high+875)*100;
if(!this.data.af.includes(freq)) this.data.af.push(freq);
}
if(this.af_am_follows) this.af_am_follows = false;
if(!(af_high == AM_FOLLOWS || af_low == FILLER || af_low == AM_FOLLOWS)) {
var freq = (af_low+875)*100;
if(!this.data.af.includes(freq)) this.data.af.push(freq);
}
if(af_low == AM_FOLLOWS) this.af_am_follows = true;
}
}
if(d_error > 2) return; // Don't risk it
const idx = blockB & 0x3;
this.ps[idx * 2] = String.fromCharCode(blockD >> 8);
this.ps[idx * 2 + 1] = String.fromCharCode(blockD & 0xFF);
this.ps_errors[idx * 2] = Math.ceil(d_error * (10/3));
this.ps_errors[idx * 2 + 1] = Math.ceil(d_error * (10/3));
this.data.ps = this.ps.join('');
this.data.ps_errors = this.ps_errors.join(',');
} else if (group === 1 && version === 0) {
if(c_error > 2) return;
var variant_code = (blockC >> 12) & 0x7;
switch (variant_code) {
case 0:
this.data.ecc = blockC & 0xff;
this.data.country_name = rdsEccLookup(blockA, this.data.ecc);
if(this.data.country_name.length === 0) this.data.country_iso = "UN";
else this.data.country_iso = iso[countries.indexOf(this.data.country_name)]
break;
default: break;
}
} else if (group === 2) {
const idx = blockB & 0b1111;
this.rt_ab = Boolean((blockB >> 4) & 1);
var multiplier = (version == 0) ? 4 : 2;
if(this.rt_ab) {
if(this.rt1_to_clear) {
this.rt1 = Array(64).fill(' ');
this.rt1_errors = Array(64).fill("0");
this.rt1_to_clear = false;
}
if(c_error < 2 && multiplier !== 2) {
this.rt1[idx * multiplier] = String.fromCharCode(blockC >> 8);
this.rt1[idx * multiplier + 1] = String.fromCharCode(blockC & 0xFF);
this.rt1_errors[idx * multiplier] = Math.ceil(c_error * (10/3));
this.rt1_errors[idx * multiplier + 1] = Math.ceil(c_error * (10/3));
}
if(d_error < 2) {
var offset = (multiplier == 2) ? 0 : 2;
this.rt1[idx * multiplier + offset] = String.fromCharCode(blockD >> 8);
this.rt1[idx * multiplier + offset + 1] = String.fromCharCode(blockD & 0xFF);
this.rt1_errors[idx * multiplier + offset] = Math.ceil(d_error * (10/3));
this.rt1_errors[idx * multiplier + offset + 1] = Math.ceil(d_error * (10/3));
}
var i = this.rt1.indexOf("\r")
while(i != -1) {
this.rt1[i] = " ";
i = this.rt1.indexOf("\r");
}
this.data.rt1 = this.rt1.join('');
this.data.rt1_errors = this.rt1_errors.join(',');
this.data.rt_flag = 1;
this.rt0_to_clear = true;
} else {
if(this.rt0_to_clear) {
this.rt0 = Array(64).fill(' ');
this.rt0_errors = Array(64).fill("0");
this.rt0_to_clear = false;
}
if(c_error !== 3 && multiplier !== 2) {
this.rt0[idx * multiplier] = String.fromCharCode(blockC >> 8);
this.rt0[idx * multiplier + 1] = String.fromCharCode(blockC & 0xFF);
this.rt0_errors[idx * multiplier] = Math.ceil(c_error * (10/3));
this.rt0_errors[idx * multiplier + 1] = Math.ceil(c_error * (10/3));
}
if(d_error !== 3) {
var offset = (multiplier == 2) ? 0 : 2;
this.rt0[idx * multiplier + offset] = String.fromCharCode(blockD >> 8);
this.rt0[idx * multiplier + offset + 1] = String.fromCharCode(blockD & 0xFF);
this.rt0_errors[idx * multiplier + offset] = Math.ceil(d_error * (10/3));
this.rt0_errors[idx * multiplier + offset + 1] = Math.ceil(d_error * (10/3));
}
var i = this.rt0.indexOf("\r");
while(i != -1) {
this.rt0[i] = " ";
i = this.rt0.indexOf("\r");
}
this.data.rt0 = this.rt0.join('');
this.data.rt0_errors = this.rt0_errors.join(',');
this.data.rt_flag = 0;
this.rt1_to_clear = true;
}
} else {
// console.log(group, version)
}
}
}
module.exports = RDSDecoder;

632
server/rds_country.js Normal file
View File

@@ -0,0 +1,632 @@
var countries = [
"Albania",
"Estonia",
"Algeria",
"Ethiopia",
"Andorra",
"Angola",
"Finland",
"Armenia",
"France",
"Ascension Island",
"Gabon",
"Austria",
"Gambia",
"Azerbaijan",
"Georgia",
"Germany",
"Bahrein",
"Ghana",
"Belarus",
"Gibraltar",
"Belgium",
"Greece",
"Benin",
"Guinea",
"Bosnia Herzegovina",
"Guinea-Bissau",
"Botswana",
"Hungary",
"Bulgaria",
"Iceland",
"Burkina Faso",
"Iraq",
"Burundi",
"Ireland",
"Cabinda",
"Israel",
"Cameroon",
"Italy",
"Jordan",
"Cape Verde",
"Kazakhstan",
"Central African Republic",
"Kenya",
"Chad",
"Kosovo",
"Comoros",
"Kuwait",
"DR Congo",
"Kyrgyzstan",
"Republic of Congo",
"Latvia",
"Cote d'Ivoire",
"Lebanon",
"Croatia",
"Lesotho",
"Cyprus",
"Liberia",
"Czechia",
"Libya",
"Denmark",
"Liechtenstein",
"Djiboutia",
"Lithuania",
"Egypt",
"Luxembourg",
"Equatorial Guinea",
"Macedonia",
"Eritrea",
"Madagascar",
"Seychelles",
"Malawi",
"Sierra Leone",
"Mali",
"Slovakia",
"Malta",
"Slovenia",
"Mauritania",
"Somalia",
"Mauritius",
"South Africa",
"Moldova",
"South Sudan",
"Monaco",
"Spain",
"Mongolia",
"Sudan",
"Montenegro",
"Swaziland",
"Morocco",
"Sweden",
"Mozambique",
"Switzerland",
"Namibia",
"Syria",
"Netherlands",
"Tajikistan",
"Niger",
"Tanzania",
"Nigeria",
"Togo",
"Norway",
"Tunisia",
"Oman",
"Turkey",
"Palestine",
"Turkmenistan",
"Poland",
"Uganda",
"Portugal",
"Ukraine",
"Qatar",
"United Arab Emirates",
"Romania",
"United Kingdom",
"Russia",
"Uzbekistan",
"Rwanda",
"Vatican",
"San Marino",
"Western Sahara",
"Sao Tome and Principe",
"Yemen",
"Saudi Arabia",
"Zambia",
"Senegal",
"Zimbabwe",
"Serbia",
"Anguilla",
"Guyana",
"Antigua and Barbuda",
"Haiti",
"Argentina",
"Honduras",
"Aruba",
"Jamaica",
"Bahamas",
"Martinique",
"Barbados",
"Mexico",
"Belize",
"Montserrat",
"Brazil/Bermuda",
"Brazil/AN",
"Bolivia",
"Nicaragua",
"Brazil",
"Panama",
"Canada",
"Paraguay",
"Cayman Islands",
"Peru",
"Chile",
"USA/VI/PR",
"Colombia",
"St. Kitts",
"Costa Rica",
"St. Lucia",
"Cuba",
"St. Pierre and Miquelon",
"Dominica",
"St. Vincent",
"Dominican Republic",
"Suriname",
"El Salvador",
"Trinidad and Tobago",
"Turks and Caicos islands",
"Falkland Islands",
"Greenland",
"Uruguay",
"Grenada",
"Venezuela",
"Guadeloupe",
"Virgin Islands",
"Guatemala",
"Afghanistan",
"South Korea",
"Laos",
"Australia Capital Territory",
"Macao",
"Australia New South Wales",
"Malaysia",
"Australia Victoria",
"Maldives",
"Australia Queensland",
"Marshall Islands",
"Australia South Australia",
"Micronesia",
"Australia Western Australia",
"Myanmar",
"Australia Tasmania",
"Nauru",
"Australia Northern Territory",
"Nepal",
"Bangladesh",
"New Zealand",
"Bhutan",
"Pakistan",
"Brunei Darussalam",
"Papua New Guinea",
"Cambodia",
"Philippines",
"China",
"Samoa",
"Singapore",
"Solomon Islands",
"Fiji",
"Sri Lanka",
"Hong Kong",
"Taiwan",
"India",
"Thailand",
"Indonesia",
"Tonga",
"Iran",
"Vanuatu",
"Japan",
"Vietnam",
"Kiribati",
"North Korea",
"Brazil/Equator"
]
var iso = [
"AL",
"EE",
"DZ",
"ET",
"AD",
"AO",
"FI",
"AM",
"FR",
"SH",
"GA",
"AT",
"GM",
"AZ",
"GE",
"DE",
"BH",
"GH",
"BY",
"GI",
"BE",
"GR",
"BJ",
"GN",
"BA",
"GW",
"BW",
"HU",
"BG",
"IS",
"BF",
"IQ",
"BI",
"IE",
"--",
"IL",
"CM",
"IT",
"JO",
"CV",
"KZ",
"CF",
"KE",
"TD",
"XK",
"KM",
"KW",
"CD",
"KG",
"CG",
"LV",
"CI",
"LB",
"HR",
"LS",
"CY",
"LR",
"CZ",
"LY",
"DK",
"LI",
"DJ",
"LT",
"EG",
"LU",
"GQ",
"MK",
"ER",
"MG",
"SC",
"MW",
"SL",
"ML",
"SK",
"MT",
"SI",
"MR",
"SO",
"MU",
"ZA",
"MD",
"SS",
"MC",
"ES",
"MN",
"SD",
"ME",
"SZ",
"MA",
"SE",
"MZ",
"CH",
"NA",
"SY",
"NL",
"TJ",
"NE",
"TZ",
"NG",
"TG",
"NO",
"TN",
"OM",
"TR",
"PS",
"TM",
"PL",
"UG",
"PT",
"UA",
"QA",
"AE",
"RO",
"GB",
"RU",
"UZ",
"RW",
"VA",
"SM",
"EH",
"ST",
"YE",
"SA",
"ZM",
"SN",
"ZW",
"RS",
"AI",
"GY",
"AG",
"HT",
"AR",
"HN",
"AW",
"JM",
"BS",
"MQ",
"BB",
"MX",
"BZ",
"MS",
"--",
"--",
"BO",
"NI",
"BR",
"PA",
"CA",
"PY",
"KY",
"PE",
"CL",
"--",
"CO",
"KN",
"CR",
"LC",
"CU",
"PM",
"DM",
"VC",
"DO",
"SR",
"SN",
"TT",
"TB",
"FK",
"GL",
"UY",
"GD",
"VE",
"GP",
"VG",
"GT",
"AF",
"KR",
"LA",
"AU",
"MO",
"AU",
"MY",
"AU",
"MV",
"AU",
"MH",
"AU",
"FM",
"AU",
"MM",
"AU",
"NR",
"AU",
"NP",
"BD",
"NZ",
"BT",
"PK",
"BN",
"PG",
"KH",
"PH",
"CN",
"WS",
"SG",
"SB",
"FJ",
"LK",
"HK",
"TW",
"IN",
"TH",
"ID",
"TO",
"IR",
"VU",
"JP",
"VN",
"KI",
"KP",
"--"
]
// RDS ECC Lookup Tables - Converted from C to JavaScript
const rdsEccA0A6Lut = [
// A0
[
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
"USA/VI/PR", null, "USA/VI/PR", "USA/VI/PR", null
],
// A1
[
null, null, null, null, null,
null, null, null, null, null,
"Canada", "Canada", "Canada", "Canada", "Greenland"
],
// A2
[
"Anguilla", "Antigua and Barbuda", "Brazil/Equator", "Falkland Islands", "Barbados",
"Belize", "Cayman Islands", "Costa Rica", "Cuba", "Argentina",
"Brazil", "Brazil/Bermuda", "Brazil/AN", "Guadeloupe", "Bahamas"
],
// A3
[
"Bolivia", "Colombia", "Jamaica", "Martinique", null,
"Paraguay", "Nicaragua", null, "Panama", "Dominica",
"Dominican Republic", "Chile", "Grenada", "Turks and Caicos islands", "Guyana"
],
// A4
[
"Guatemala", "Honduras", "Aruba", null, "Montserrat",
"Trinidad and Tobago", "Peru", "Suriname", "Uruguay", "St. Kitts",
"St. Lucia", "El Salvador", "Haiti", "Venezuela", "Virgin Islands"
],
// A5
[
null, null, null, null, null,
null, null, null, null, null,
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"
],
// A6
[
null, null, null, null, null,
null, null, null, null, null,
null, null, null, null, "St. Pierre and Miquelon"
]
];
const rdsEccD0D4Lut = [
// D0
[
"Cameroon", "Central African Republic", "Djiboutia", "Madagascar", "Mali",
"Angola", "Equatorial Guinea", "Gabon", "Guinea", "South Africa",
"Burkina Faso", "Republic of Congo", "Togo", "Benin", "Malawi"
],
// D1
[
"Namibia", "Liberia", "Ghana", "Mauritania", "Sao Tome and Principe",
"Cape Verde", "Senegal", "Gambia", "Burundi", "Ascension Island",
"Botswana", "Comoros", "Tanzania", "Ethiopia", "Nigeria"
],
// D2
[
"Sierra Leone", "Zimbabwe", "Mozambique", "Uganda", "Swaziland",
"Kenya", "Somalia", "Niger", "Chad", "Guinea-Bissau",
"DR Congo", "Cote d'Ivoire", null, "Zambia", "Eritrea"
],
// D3
[
null, null, "Western Sahara", "Cabinda", "Rwanda",
"Lesotho", null, "Seychelles", null, "Mauritius",
null, "Sudan", null, null, null
],
// D4
[
null, null, null, null, null,
null, null, null, null, "South Sudan",
null, null, null, null, null
]
];
const rdsEccE0E5Lut = [
// E0
[
"Germany", "Algeria", "Andorra", "Israel", "Italy",
"Belgium", "Russia", "Palestine", "Albania", "Austria",
"Hungary", "Malta", "Germany", null, "Egypt"
],
// E1
[
"Greece", "Cyprus", "San Marino", "Switzerland", "Jordan",
"Finland", "Luxembourg", "Bulgaria", "Denmark", "Gibraltar",
"Iraq", "United Kingdom", "Libya", "Romania", "France"
],
// E2
[
"Morocco", "Czechia", "Poland", "Vatican", "Slovakia",
"Syria", "Tunisia", null, "Liechtenstein", "Iceland",
"Monaco", "Lithuania", "Serbia", "Spain", "Norway"
],
// E3
[
"Montenegro", "Ireland", "Turkey", null, "Tajikistan",
null, null, "Netherlands", "Latvia", "Lebanon",
"Azerbaijan", "Croatia", "Kazakhstan", "Sweden", "Belarus"
],
// E4
[
"Moldova", "Estonia", "Macedonia", null, null,
"Ukraine", "Kosovo", "Portugal", "Slovenia", "Armenia",
"Uzbekistan", "Georgia", null, "Turkmenistan", "Bosnia Herzegovina"
],
// E5
[
null, null, "Kyrgyzstan", null, null,
null, null, null, null, null,
null, null, null, null, null
]
];
const rdsEccF0F4Lut = [
// F0
[
"Australia Capital Territory", "Australia New South Wales", "Australia Victoria", "Australia Queensland", "Australia South Australia",
"Australia Western Australia", "Australia Tasmania", "Australia Northern Territory", "Saudi Arabia", "Afghanistan",
"Myanmar", "China", "North Korea", "Bahrein", "Malaysia"
],
// F1
[
"Kiribati", "Bhutan", "Bangladesh", "Pakistan", "Fiji",
"Oman", "Nauru", "Iran", "New Zealand", "Solomon Islands",
"Brunei Darussalam", "Sri Lanka", "Taiwan", "South Korea", "Hong Kong"
],
// F2
[
"Kuwait", "Qatar", "Cambodia", "Samoa", "India",
"Macao", "Vietnam", "Philippines", "Japan", "Singapore",
"Maldives", "Indonesia", "United Arab Emirates", "Nepal", "Vanuatu"
],
// F3
[
"Laos", "Thailand", "Tonga", null, null,
null, null, "China", "Papua New Guinea", null,
"Yemen", null, null, "Micronesia", "Mongolia"
],
// F4
[
null, null, null, null, null,
null, null, null, "China", null,
"Marshall Islands", null, null, null, null
]
];
function rdsEccLookup(pi, ecc) {
const PI_UNKNOWN = -1;
const piCountry = (pi >> 12) & 0xF;
if (pi === PI_UNKNOWN || piCountry === 0) {
return ""
}
const piId = piCountry - 1;
const eccRanges = [
{ min: 0xA0, max: 0xA6, lut: rdsEccA0A6Lut },
{ min: 0xD0, max: 0xD4, lut: rdsEccD0D4Lut },
{ min: 0xE0, max: 0xE5, lut: rdsEccE0E5Lut },
{ min: 0xF0, max: 0xF4, lut: rdsEccF0F4Lut }
];
// Check each range
for (const range of eccRanges) {
if (ecc >= range.min && ecc <= range.max) {
const eccId = ecc - range.min;
return range.lut[eccId][piId];
}
}
return ""
}
module.exports = {
rdsEccLookup,
iso,
countries
};

View File

@@ -1,7 +1,7 @@
/* Libraries / Imports */
const fs = require('fs');
const path = require('path');
const { logDebug, logError, logInfo, logWarn } = require('./console');
const { logError, logInfo } = require('./console');
let configName = 'config';
@@ -90,11 +90,14 @@ let serverConfig = {
fmlistAdminOnly: false,
fmlistOmid: "",
},
si47xx: {
agcControl: false
},
tunnel: {
enabled: false,
username: "",
token: "",
region: "eu",
region: "pldx",
lowLatencyMode: false,
subdomain: "",
httpName: "",
@@ -130,15 +133,9 @@ let serverConfig = {
function addMissingFields(target, source) {
Object.keys(source).forEach(function(key) {
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
if (!target[key]) {
target[key] = {}; // Create missing object
}
if (!target[key]) target[key] = {}; // Create missing object
addMissingFields(target[key], source[key]); // Recursively add missing fields
} else {
if (target[key] === undefined) {
target[key] = source[key]; // Add missing fields only
}
}
} else if(target[key] === undefined) target[key] = source[key]; // Add missing fields only
});
}
@@ -146,13 +143,9 @@ function addMissingFields(target, source) {
function deepMerge(target, source) {
Object.keys(source).forEach(function(key) {
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object') {
target[key] = {}; // Ensure target[key] is an object before merging
}
if (!target[key] || typeof target[key] !== 'object') target[key] = {}; // Ensure target[key] is an object before merging
deepMerge(target[key], source[key]); // Recursively merge objects
} else {
target[key] = source[key]; // Overwrite or add the value
}
} else target[key] = source[key]; // Overwrite or add the value
});
}

View File

@@ -1,381 +0,0 @@
"use strict";
/*
Stdin streamer is part of 3LAS (Low Latency Live Audio Streaming)
https://github.com/JoJoBond/3LAS
*/
var fs = require('fs');
const path = require('path');
const checkFFmpeg = require('./checkFFmpeg');
const { spawn } = require('child_process');
const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console');
const { serverConfig } = require('../server_config');
let ffmpegStaticPath = 'ffmpeg'; // fallback value
let ServerInstance;
let handleAudioUpgradeFn;
let readyResolve;
const waitUntilReady = new Promise((resolve) => {
readyResolve = resolve;
});
checkFFmpeg().then((resolvedPath) => {
ffmpegStaticPath = resolvedPath;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs");
const child_process_1 = require("child_process");
const ws = __importStar(require("ws"));
const Settings = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'settings.json'), 'utf-8'));
const FFmpeg_command = ffmpegStaticPath;
class StreamClient {
constructor(server, socket) {
this.Server = server;
this.Socket = socket;
this.BinaryOptions = {
compress: false,
binary: true
};
this.Socket.on('error', this.OnError.bind(this));
this.Socket.on('message', this.OnMessage.bind(this));
}
OnMessage(message, isBinary) {
try {
let request = JSON.parse(message.toString());
if (request.type == "answer") {
}
else if (request.type == "fallback") {
this.Server.SetFallback(this, request.data);
}
else if (request.type == "stats") {
if (Settings.AdminKey && request.data == Settings.AdminKey) {
this.SendText(JSON.stringify({
"type": "stats",
"data": this.Server.GetStats(),
}));
}
}
else {
this.OnError(null);
return;
}
}
catch (_a) {
this.OnError(null);
return;
}
}
OnError(_err) {
this.Server.DestroyClient(this);
}
Destroy() {
try {
this.Socket.close();
}
catch (ex) {
}
}
SendBinary(buffer) {
if (this.Socket.readyState != ws.OPEN) {
this.OnError(null);
return;
}
this.Socket.send(buffer, this.BinaryOptions);
}
SendText(text) {
if (this.Socket.readyState != ws.OPEN) {
this.OnError(null);
return;
}
this.Socket.send(text);
}
OnIceCandidate(e) {
if (e.candidate) {
this.SendText(JSON.stringify({
"type": "candidate",
"data": e.candidate
}));
}
}
}
class StreamServer {
constructor(port, channels, sampleRate) {
this.Port = port || null;
this.Channels = channels;
this.SampleRate = sampleRate;
this.Clients = new Set();
this.FallbackClients = {
"wav": new Set(),
"mp3": new Set()
};
this.FallbackProvider = {};
if (Settings.FallbackUseMp3) {
this.FallbackProvider["mp3"] = AFallbackProvider.Create(this, "mp3");
}
if (Settings.FallbackUseWav) {
this.FallbackProvider["wav"] = AFallbackProvider.Create(this, "wav");
}
this.StdIn = process.stdin;
this.SamplesCount = this.SampleRate / 100;
this.Samples = new Int16Array(this.Channels * this.SamplesCount);
this.SamplesPosition = 0;
}
Run() {
this.Server = new ws.Server({
noServer: true,
clientTracking: true,
perMessageDeflate: false,
});
// Allow manual upgrade handling from index.js
this.handleUpgrade = (req, socket, head) => {
this.Server.handleUpgrade(req, socket, head, (ws) => {
this.Server.emit('connection', ws, req);
});
};
this.Server.on('connection', this.OnServerConnection.bind(this));
if (!this.StdIn) {
logError('[Stream] No audio input stream defined (this.StdIn is null)');
return;
}
this.StdIn.on('data', this.OnStdInData.bind(this));
this.StdIn.resume();
}
BroadcastBinary(format, buffer) {
this.FallbackClients[format].forEach((function each(client) {
client.SendBinary(buffer);
}).bind(this));
}
OnStdInData(buffer) {
for (let i = 0; i < buffer.length; i += 2) {
this.Samples[this.SamplesPosition] = buffer.readInt16LE(i);
this.SamplesPosition++;
if (this.SamplesPosition >= this.Samples.length) {
let data = {
"samples": this.Samples,
"sampleRate": this.SampleRate,
"bitsPerSample": 16,
"channelCount": this.Channels,
"numberOfFrames": this.SamplesCount,
};
this.Samples = new Int16Array(this.Channels * this.SamplesCount);
this.SamplesPosition = 0;
}
}
for (let format in this.FallbackProvider) {
this.FallbackProvider[format].InsertData(buffer);
}
}
OnServerConnection(socket, _request) {
this.Clients.add(new StreamClient(this, socket));
}
SetFallback(client, format) {
if (format != "mp3" && format != "wav") {
this.DestroyClient(client);
return;
}
this.FallbackClients[format].add(client);
this.FallbackProvider[format].PrimeClient(client);
}
DestroyClient(client) {
this.FallbackClients["mp3"].delete(client);
this.FallbackClients["wav"].delete(client);
this.Clients.delete(client);
client.Destroy();
}
GetStats() {
let fallback = {
"wav": (this.FallbackClients["wav"] ? this.FallbackClients["wav"].size : 0),
"mp3": (this.FallbackClients["mp3"] ? this.FallbackClients["mp3"].size : 0),
};
let total = 0;
for (let format in fallback) {
total += fallback[format];
}
return {
"Total": total,
"Fallback": fallback,
};
}
static Create(options) {
// Allow Port to be omitted
const port = options["-port"] || null;
if (!options["-channels"])
throw new Error("Channels undefined. Please use -channels to define the number of channels.");
if (typeof options["-channels"] !== "number" || options["-channels"] !== Math.floor(options["-channels"]) ||
!(options["-channels"] == 1 || options["-channels"] == 2))
throw new Error("Invalid channels. Must be either 1 or 2.");
if (!options["-samplerate"])
throw new Error("Sample rate undefined. Please use -samplerate to define the sample rate.");
if (typeof options["-samplerate"] !== "number" || options["-samplerate"] !== Math.floor(options["-samplerate"]) || options["-samplerate"] < 1)
throw new Error("Invalid sample rate. Must be natural number greater than 0.");
return new StreamServer(port, options["-channels"], options["-samplerate"]);
}
}
class AFallbackProvider {
constructor(server) {
this.Server = server;
this.Process = (0, child_process_1.spawn)(FFmpeg_command, this.GetFFmpegArguments(), { shell: false, detached: false, stdio: ['pipe', 'pipe', 'ignore'] });
this.Process.stdout.addListener('data', this.OnData.bind(this));
}
InsertData(buffer) {
this.Process.stdin.write(buffer);
}
static Create(server, format) {
if (format == "mp3") {
return new FallbackProviderMp3(server);
}
else if (format == "wav") {
return new FallbackProviderWav(server, 384);
}
}
}
class FallbackProviderMp3 extends AFallbackProvider {
constructor(server) {
super(server);
}
GetFFmpegArguments() {
return [
"-fflags", "+nobuffer+flush_packets", "-flags", "low_delay", "-rtbufsize", "32", "-probesize", "32",
"-f", "s16le",
"-ar", Number(this.Server.SampleRate.toString()) + Number(serverConfig.audio.samplerateOffset),
"-ac", this.Server.Channels.toString(),
"-i", "pipe:0",
"-c:a", "libmp3lame",
"-b:a", serverConfig.audio.audioBitrate,
"-ac", this.Server.Channels.toString(),
"-reservoir", "0",
"-f", "mp3", "-write_xing", "0", "-id3v2_version", "0",
"-fflags", "+nobuffer", "-flush_packets", "1",
"pipe:1"
];
}
OnData(chunk) {
this.Server.BroadcastBinary("mp3", chunk);
}
PrimeClient(_) {
}
}
class FallbackProviderWav extends AFallbackProvider {
constructor(server, chunkSize) {
super(server);
if (typeof chunkSize !== "number" || chunkSize !== Math.floor(chunkSize) || chunkSize < 1)
throw new Error("Invalid ChunkSize. Must be natural number greater than or equal to 1.");
this.ChunkSize = chunkSize;
this.ChunkBuffer = Buffer.alloc(0);
this.HeaderBuffer = new Array();
}
GetFFmpegArguments() {
return [
"-fflags", "+nobuffer+flush_packets", "-flags", "low_delay", "-rtbufsize", "32", "-probesize", "32",
"-f", "s16le",
"-ar", Number(this.Server.SampleRate.toString()) + Number(serverConfig.audio.samplerateOffset),
"-ac", this.Server.Channels.toString(),
"-i", "pipe:0",
"-c:a", "pcm_s16le",
"-ar", Settings.FallbackWavSampleRate.toString(),
"-ac", "1",
"-f", "wav",
"-flush_packets", "1", "-fflags", "+nobuffer", "-chunk_size", "384", "-packetsize", "384",
"pipe:1"
];
}
OnData(chunk) {
// Check if riff for wav
if (this.HeaderBuffer.length == 0) {
// Check if chunk is a header page
let isHeader = (chunk[0] == 0x52 && chunk[1] == 0x49 && chunk[2] == 0x46 && chunk[3] == 0x46);
if (isHeader) {
this.HeaderBuffer.push(chunk);
this.Server.BroadcastBinary("wav", chunk);
}
}
else {
this.ChunkBuffer = Buffer.concat(new Array(this.ChunkBuffer, chunk), this.ChunkBuffer.length + chunk.length);
if (this.ChunkBuffer.length >= this.ChunkSize) {
let chunkBuffer = this.ChunkBuffer;
this.ChunkBuffer = Buffer.alloc(0);
this.Server.BroadcastBinary("wav", chunkBuffer);
}
}
}
PrimeClient(client) {
let headerBuffer = this.HeaderBuffer;
for (let i = 0; i < headerBuffer.length; i++) {
client.SendBinary(headerBuffer[i]);
}
}
}
/* Parsing parameters no longer required for Server variable but we'll keep the old code here as a reference
const OptionParser = {
"-port": function (txt) { return parseInt(txt, 10); },
"-channels": function (txt) { return parseInt(txt, 10); },
"-samplerate": function (txt) { return parseInt(txt, 10); }
};
const Options = {};
// Parse parameters
for (let i = 2; i < (process.argv.length - 1); i += 2) {
if (!OptionParser[process.argv[i]])
throw new Error("Invalid argument: '" + process.argv[i] + "'.");
if (Options[process.argv[i]])
throw new Error("Redefined argument: '" + process.argv[i] + "'. Please use '" + process.argv[i] + "' only ONCE");
Options[process.argv[i]] = OptionParser[process.argv[i]](process.argv[i + 1]);
}
*/
const audioChannels = serverConfig.audio.audioChannels || 2;
const Server = new StreamServer(null, audioChannels, 48000);
ServerInstance = Server;
handleAudioUpgradeFn = function (request, socket, head, cb) {
if (Server.Server && Server.Server.handleUpgrade) {
Server.Server.handleUpgrade(request, socket, head, cb);
} else {
socket.destroy();
}
};
readyResolve();
}).catch((err) => {
logError('[Stream] Error:', err);
});
module.exports = {
get Server() {
return ServerInstance;
},
get handleAudioUpgrade() {
return handleAudioUpgradeFn;
},
waitUntilReady
};
//# sourceMappingURL=3las.server.js.map

View File

@@ -11,11 +11,8 @@ function checkFFmpeg() {
});
checkFFmpegProcess.on('exit', (code) => {
if (code === 0) {
resolve('ffmpeg');
} else {
resolve(require('ffmpeg-static'));
}
if (code === 0) resolve('ffmpeg');
else resolve(require('ffmpeg-static'));
});
});
}

View File

@@ -1,8 +1,8 @@
const { spawn, execSync } = require('child_process');
const { configName, serverConfig, configUpdate, configSave, configExists } = require('../server_config');
const { spawn } = require('child_process');
const { serverConfig } = require('../server_config');
const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console');
const checkFFmpeg = require('./checkFFmpeg');
const audioServer = require('./3las.server');
const { PassThrough } = require('stream');
const consoleLogTitle = '[Audio Stream]';
@@ -15,384 +15,128 @@ function connectMessage(message) {
}
}
function checkAudioUtilities() {
if (process.platform === 'darwin') {
try {
execSync('which sox');
} catch (error) {
logError(`${consoleLogTitle} Error: SoX ("sox") not found, Please install sox.`);
process.exit(1);
}
} else if (process.platform === 'linux') {
try {
execSync('which arecord');
} catch (error) {
logError(`${consoleLogTitle} Error: ALSA ("arecord") not found. Please install ALSA utils.`);
process.exit(1);
}
}
}
function buildCommand(ffmpegPath) {
const inputDevice = serverConfig.audio.audioDevice || 'Stereo Mix';
const audioChannels = serverConfig.audio.audioChannels || 2;
const webPort = Number(serverConfig.webserver.webserverPort);
// Common audio options for FFmpeg
const baseOptions = {
flags: ['-fflags', '+nobuffer+flush_packets', '-flags', 'low_delay', '-rtbufsize', '6192', '-probesize', '32'],
codec: ['-acodec', 'pcm_s16le', '-ar', '48000', '-ac', `${audioChannels}`],
output: ['-f', 's16le', '-fflags', '+nobuffer+flush_packets', '-packetsize', '384', '-flush_packets', '1', '-bufsize', '960', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '10', 'pipe:1']
};
// Windows
if (process.platform === 'win32') {
logInfo(`${consoleLogTitle} Platform: Windows (win32). Using "dshow" input.`);
return {
command: ffmpegPath,
args: [
...baseOptions.flags,
'-f', 'dshow',
'-audio_buffer_size', '200',
'-i', `audio=${inputDevice}`,
...baseOptions.codec,
...baseOptions.output
]
};
} else if (process.platform === 'darwin') {
// macOS
if (!serverConfig.audio.ffmpeg) {
logInfo(`${consoleLogTitle} Platform: macOS (darwin) using "coreaudio"`);
return {
args: [],
soxArgs: [
'-t', 'coreaudio', `${inputDevice}`,
'-b', '32',
'-r', '48000',
'-c', `${audioChannels}`,
'-t', 'raw',
'-b', '16',
'-r', '48000',
'-c', `${audioChannels}`
, '-'
]
};
} else {
const device = serverConfig.audio.audioDevice;
return {
command: ffmpegPath,
args: [
...baseOptions.flags,
'-f', 'avfoundation',
'-i', `${device || ':0'}`,
...baseOptions.codec,
...baseOptions.output
]
};
}
} else {
// Linux
if (!serverConfig.audio.ffmpeg) {
const prefix = serverConfig.audio.softwareMode ? 'plug' : '';
const device = `${prefix}${serverConfig.audio.audioDevice}`;
logInfo(`${consoleLogTitle} Platform: Linux. Using "alsa" input.`);
return {
// command not used if arecordArgs are used
command: `while true; do arecord -D "${device}" -f S16_LE -r 48000 -c ${audioChannels} -t raw; done`,
args: [],
arecordArgs: [
'-D', device,
'-f', 'S16_LE',
'-r', '48000',
'-c', audioChannels,
'-t', 'raw'
],
ffmpegArgs: []
};
} else {
const device = serverConfig.audio.audioDevice;
return {
command: ffmpegPath,
args: [
...baseOptions.flags,
'-f', 'alsa',
'-i', `${device}`,
...baseOptions.codec,
...baseOptions.output
],
arecordArgs: [],
};
}
}
}
const audio_pipe = new PassThrough();
checkFFmpeg().then((ffmpegPath) => {
if (!serverConfig.audio.ffmpeg) checkAudioUtilities();
let audioErrorLogged = false;
logInfo(`${consoleLogTitle} Using ${ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static'}`);
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
logInfo(`${consoleLogTitle} Using`, ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static');
const sampleRate = Number(serverConfig.audio.sampleRate || 44100) + Number(serverConfig.audio.samplerateOffset || 0); // Maybe even do 32 khz, we do not need higher than 15 khz precision
if (process.platform !== 'darwin') {
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
} else {
logInfo(`${consoleLogTitle} Starting audio stream on default input device.`);
const channels = Number(serverConfig.audio.audioChannels || 2);
let ffmpeg = null;
let restartTimer = null;
let lastTimestamp = null;
let staleCount = 0;
let lastCheckTime = Date.now();
function buildArgs() {
const device = serverConfig.audio.audioDevice;
let inputArgs;
if (process.platform === 'win32') inputArgs = ["-f", "dshow", "-i", `audio=${device}`];
else if (process.platform === 'darwin') inputArgs = ["-f", "avfoundation", "-i", device || ":0"];
else inputArgs = ["-f", "alsa", "-i", device];
return [
"-fflags", "+flush_packets",
"-flags", "low_delay",
"-rtbufsize", "4096",
"-probesize", "128",
...inputArgs,
"-thread_queue_size", "1536",
"-ar", String(sampleRate),
"-ac", String(channels),
"-c:a", "libmp3lame",
"-b:a", serverConfig.audio.audioBitrate,
"-ac", String(channels),
"-reservoir", "0",
"-f", "mp3",
"-write_xing", "0",
"-id3v2_version", "0",
"-fflags", "+nobuffer",
"-flush_packets", "1",
"pipe:1"
];
}
if (process.platform === 'win32') {
// Windows (FFmpeg DirectShow Capture)
let ffmpeg;
let restartTimer = null;
let lastTimestamp = null;
let lastCheckTime = Date.now();
let audioErrorLogged = false;
let staleCount = 0;
function launchFFmpeg() {
const args = buildArgs();
function launchFFmpeg() {
const commandDef = buildCommand(ffmpegPath);
let ffmpegArgs = commandDef.args;
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${args.join(' ')}`);
// Apply audio boost if enabled
if (serverConfig.audio.audioBoost) {
ffmpegArgs.splice(ffmpegArgs.indexOf('pipe:1'), 0, '-af', 'volume=1.7');
}
ffmpeg = spawn(ffmpegPath, args, {stdio: ['ignore', 'pipe', 'pipe']});
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${ffmpegArgs.join(' ')}`);
ffmpeg = spawn(ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
ffmpeg.stdout.pipe(audio_pipe, { end: false });
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = ffmpeg.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected FFmpeg (capture) \u2192 FFmpeg (process) \u2192 Server.StdIn${serverConfig.audio.audioBoost ? ' (audio boost)' : ''}`);
});
connectMessage(`${consoleLogTitle} Connected FFmpeg → MP3 → audioWss`);
ffmpeg.stderr.on('data', (data) => {
const msg = data.toString();
logFfmpeg(`[FFmpeg stderr]: ${msg}`);
ffmpeg.stderr.on('data', (data) => {
const msg = data.toString();
logFfmpeg(`[FFmpeg stderr]: ${msg}`);
if (msg.includes('I/O error') && !audioErrorLogged) {
audioErrorLogged = true;
logError(`${consoleLogTitle} Audio device "${serverConfig.audio.audioDevice}" failed to start.`);
logError('Please start the server with: node . --ffmpegdebug for more info.');
}
// Detect frozen timestamps
const match = msg.match(/time=(\d\d):(\d\d):(\d\d\.\d+)/);
if (match) {
const [_, hh, mm, ss] = match;
const totalSec = parseInt(hh) * 3600 + parseInt(mm) * 60 + parseFloat(ss);
// Detect frozen timestamp
const match = msg.match(/time=(\d\d):(\d\d):(\d\d\.\d+)/);
if (match) {
const [_, hh, mm, ss] = match;
const totalSec = parseInt(hh) * 3600 + parseInt(mm) * 60 + parseFloat(ss);
if (lastTimestamp !== null && totalSec === lastTimestamp) {
staleCount++;
const now = Date.now();
if (lastTimestamp !== null && totalSec === lastTimestamp) {
const now = Date.now();
staleCount++;
if (staleCount >= 10 && now - lastCheckTime > 10000 && !restartTimer) {
restartTimer = setTimeout(() => {
restartTimer = null;
staleCount = 0;
try {
ffmpeg.kill('SIGKILL');
} catch (e) {
logWarn(`${consoleLogTitle} Failed to kill FFmpeg process: ${e.message}`);
}
launchFFmpeg(); // Restart FFmpeg
}, 0);
setTimeout(() => logWarn(`${consoleLogTitle} FFmpeg appears frozen. Restarting...`), 100);
}
} else {
lastTimestamp = totalSec;
lastCheckTime = Date.now();
staleCount = 0;
if (staleCount >= 10 && now - lastCheckTime > 10000 && !restartTimer) {
logWarn(`${consoleLogTitle} FFmpeg appears frozen. Restarting...`);
restartTimer = setTimeout(() => {
restartTimer = null;
staleCount = 0;
try {
ffmpeg.kill('SIGKILL');
} catch (e) {
logWarn(`${consoleLogTitle} Failed to kill FFmpeg: ${e.message}`);
}
launchFFmpeg();
}, 0);
}
}
});
ffmpeg.on('exit', (code, signal) => {
if (signal) {
logFfmpeg(`[FFmpeg exited] with signal ${signal}`);
logWarn(`${consoleLogTitle} FFmpeg was killed with signal ${signal}`);
} else {
logFfmpeg(`[FFmpeg exited] with code ${code}`);
if (code !== 0) {
logWarn(`${consoleLogTitle} FFmpeg exited unexpectedly with code ${code}`);
}
lastTimestamp = totalSec;
lastCheckTime = Date.now();
staleCount = 0;
}
// Retry on device fail
if (audioErrorLogged) {
logWarn(`${consoleLogTitle} Retrying in 10 seconds...`);
setTimeout(() => {
audioErrorLogged = false;
launchFFmpeg();
}, 10000);
}
});
}
launchFFmpeg(); // Initial launch
} else if (process.platform === 'darwin') {
// macOS (sox --> 3las.server.js --> FFmpeg)
const commandDef = buildCommand(ffmpegPath);
// Apply audio boost if enabled and FFmpeg is used
if (serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg) {
commandDef.args.splice(commandDef.soxArgs.indexOf('pipe:1'), 0, '-af', 'volume=1.7');
}
let currentSox = null;
process.on('exit', () => {
if (currentSox) currentSox.kill('SIGINT');
});
process.on('SIGINT', () => {
if (currentSox) currentSox.kill('SIGINT');
process.exit();
});
function startSox() {
if (!serverConfig.audio.ffmpeg) {
// Spawn sox
logDebug(`${consoleLogTitle} Launching sox with args: ${commandDef.soxArgs.join(' ')}`);
const sox = spawn('sox', commandDef.soxArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
currentSox = sox;
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = sox.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected SoX \u2192 FFmpeg \u2192 Server.StdIn${serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg ? ' (audio boost)' : ''}`);
});
sox.stderr.on('data', (data) => {
logFfmpeg(`[sox stderr]: ${data}`);
});
sox.on('exit', (code) => {
logFfmpeg(`[sox exited] with code ${code}`);
if (code !== 0) {
setTimeout(startSox, 2000);
}
});
}
}
startSox();
if (serverConfig.audio.ffmpeg) {
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${commandDef.args.join(' ')}`);
const ffmpeg = spawn(ffmpegPath, commandDef.args, { stdio: ['ignore', 'pipe', 'pipe'] });
// Pipe FFmpeg output to 3las.server.js
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = ffmpeg.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected FFmpeg stdout \u2192 Server.StdIn${serverConfig.audio.audioBoost ? ' (audio boost)' : ''}`);
});
process.on('SIGINT', () => {
ffmpeg.kill('SIGINT');
process.exit();
});
process.on('exit', () => {
ffmpeg.kill('SIGINT');
});
// FFmpeg stderr handling
ffmpeg.stderr.on('data', (data) => {
logFfmpeg(`[FFmpeg stderr]: ${data}`);
});
// FFmpeg exit handling
ffmpeg.on('exit', (code) => {
logFfmpeg(`[FFmpeg exited] with code ${code}`);
if (code !== 0) {
logWarn(`${consoleLogTitle} FFmpeg exited unexpectedly with code ${code}`);
}
});
}
} else {
// Linux (arecord --> 3las.server.js --> FFmpeg)
const commandDef = buildCommand(ffmpegPath);
// Apply audio boost if enabled and FFmpeg is used
if (serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg) {
commandDef.args.splice(commandDef.args.indexOf('pipe:1'), 0, '-af', 'volume=1.7');
}
let currentArecord = null;
process.on('exit', () => {
if (currentArecord) currentArecord.kill('SIGINT');
});
process.on('SIGINT', () => {
if (currentArecord) currentArecord.kill('SIGINT');
process.exit();
ffmpeg.on('exit', (code, signal) => {
if (signal) logWarn(`${consoleLogTitle} FFmpeg killed with signal ${signal}`);
else if (code !== 0) logWarn(`${consoleLogTitle} FFmpeg exited with code ${code}`);
logWarn(`${consoleLogTitle} Restarting FFmpeg in 5 seconds...`);
setTimeout(launchFFmpeg, 5000);
});
function startArecord() {
if (!serverConfig.audio.ffmpeg) {
// Spawn the arecord loop
logDebug(`${consoleLogTitle} Launching arecord with args: ${commandDef.arecordArgs.join(' ')}`);
//const arecord = spawn(commandDef.command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
const arecord = spawn('arecord', commandDef.arecordArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
currentArecord = arecord;
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = arecord.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected arecord \u2192 FFmpeg \u2192 Server.StdIn${serverConfig.audio.audioBoost && serverConfig.audio.ffmpeg ? ' (audio boost)' : ''}`);
});
arecord.stderr.on('data', (data) => {
logFfmpeg(`[arecord stderr]: ${data}`);
});
arecord.on('exit', (code) => {
logFfmpeg(`[arecord exited] with code ${code}`);
if (code !== 0) {
setTimeout(startArecord, 2000);
}
});
}
}
startArecord();
if (serverConfig.audio.ffmpeg) {
logDebug(`${consoleLogTitle} Launching FFmpeg with args: ${commandDef.args.join(' ')}`);
const ffmpeg = spawn(ffmpegPath, commandDef.args, { stdio: ['ignore', 'pipe', 'pipe'] });
// Pipe FFmpeg output to 3las.server.js
audioServer.waitUntilReady.then(() => {
audioServer.Server.StdIn = ffmpeg.stdout;
audioServer.Server.Run();
connectMessage(`${consoleLogTitle} Connected FFmpeg stdout \u2192 Server.StdIn${serverConfig.audio.audioBoost ? ' (audio boost)' : ''}`);
});
process.on('SIGINT', () => {
ffmpeg.kill('SIGINT');
process.exit();
});
process.on('exit', () => {
ffmpeg.kill('SIGINT');
});
// FFmpeg stderr handling
ffmpeg.stderr.on('data', (data) => {
logFfmpeg(`[FFmpeg stderr]: ${data}`);
});
// FFmpeg exit handling
ffmpeg.on('exit', (code) => {
logFfmpeg(`[FFmpeg exited] with code ${code}`);
if (code !== 0) {
logWarn(`${consoleLogTitle} FFmpeg exited unexpectedly with code ${code}`);
}
});
}
}
process.on('SIGINT', () => {
if (ffmpeg) ffmpeg.kill('SIGINT');
process.exit();
});
process.on('exit', () => {
if (ffmpeg) ffmpeg.kill('SIGINT');
});
launchFFmpeg();
}).catch((err) => {
logError(`${consoleLogTitle} Error: ${err.message}`);
});
module.exports = audio_pipe;

View File

@@ -28,9 +28,7 @@ function parseAudioDevice(options, callback) {
const matches = (data.match(regex) || []).map(match => 'hw:' + match.replace(/\s+/g, '').slice(1, -1));
matches.forEach(match => {
if (typeof match === 'string') {
audioDevices.push({ name: match });
}
if (typeof match === 'string') audioDevices.push({ name: match });
});
} catch (err) {
console.error(`Error reading file: ${err.message}`);
@@ -74,9 +72,7 @@ function parseAudioDevice(options, callback) {
if (platform === 'win32' && line.search(/Alternative\sname/) > -1) {
const lastDevice = deviceList[deviceList.length - 1];
const alt = line.match(alternativeName);
if (lastDevice && alt) {
lastDevice.alternativeName = alt[1];
}
if (lastDevice && alt) lastDevice.alternativeName = alt[1];
return;
}
@@ -107,11 +103,8 @@ function parseAudioDevice(options, callback) {
}
};
if (callbackExists) {
execute();
} else {
return new Promise(execute);
}
if (callbackExists) execute();
else return new Promise(execute);
}
module.exports = { parseAudioDevice };

View File

@@ -1,9 +0,0 @@
{
"RtcConfig" : null,
"FallbackFFmpegPath": "ffmpeg.exe",
"FallbackUseMp3": true,
"FallbackUseWav": false,
"FallbackMp3Bitrate": 192,
"FallbackWavSampleRate": 16000,
"AdminKey": ""
}

28
server/stream/ws.js Normal file
View File

@@ -0,0 +1,28 @@
const WebSocket = require('ws');
const { serverConfig } = require('../server_config');
const audio_pipe = require('./index.js');
const audioWss = new WebSocket.Server({ noServer: true, skipUTF8Validation: true });
audioWss.on('connection', (ws, request) => {
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
if (serverConfig.webserver.banlist?.includes(clientIp)) {
ws.close(1008, 'Banned IP');
return;
}
});
audio_pipe.on('data', (chunk) => {
audioWss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) client.send(chunk, {binary: true, compress: false});
});
});
audio_pipe.on('end', () => {
audioWss.clients.forEach((client) => {
client.close(1001, "Audio stream ended");
});
});
module.exports = audioWss;

84
server/tuner_profiles.js Normal file
View File

@@ -0,0 +1,84 @@
const tunerProfiles = [
{
id: 'tef',
label: 'TEF668x',
fmBandwidths: [
{ value: 0, label: 'Auto' },
{ value: 56000, label: '56 kHz' },
{ value: 64000, label: '64 kHz' },
{ value: 72000, label: '72 kHz' },
{ value: 84000, label: '84 kHz' },
{ value: 97000, label: '97 kHz' },
{ value: 114000, label: '114 kHz' },
{ value: 133000, label: '133 kHz' },
{ value: 151000, label: '151 kHz' },
{ value: 184000, label: '184 kHz' },
{ value: 200000, label: '200 kHz' },
{ value: 217000, label: '217 kHz' },
{ value: 236000, label: '236 kHz' },
{ value: 254000, label: '254 kHz' },
{ value: 287000, label: '287 kHz' },
{ value: 311000, label: '311 kHz' }
],
details: ''
},
{
id: 'xdr',
label: 'XDR (F1HD / S10HDiP)',
fmBandwidths: [
{ value: 0, value2: -1, label: 'Auto' },
{ value: 55000, value2: 0, label: '55 kHz' },
{ value: 73000, value2: 1, label: '73 kHz' },
{ value: 90000, value2: 2, label: '90 kHz' },
{ value: 108000, value2: 3, label: '108 kHz' },
{ value: 125000, value2: 4, label: '125 kHz' },
{ value: 142000, value2: 5, label: '142 kHz' },
{ value: 159000, value2: 6, label: '159 kHz' },
{ value: 177000, value2: 7, label: '177 kHz' },
{ value: 194000, value2: 8, label: '194 kHz' },
{ value: 211000, value2: 9, label: '211 kHz' },
{ value: 229000, value2: 10, label: '229 kHz' },
{ value: 246000, value2: 11, label: '246 kHz' },
{ value: 263000, value2: 12, label: '263 kHz' },
{ value: 281000, value2: 13, label: '281 kHz' },
{ value: 298000, value2: 14, label: '298 kHz' },
{ value: 309000, value2: 15, label: '309 kHz' }
],
details: ''
},
{
id: 'sdr',
label: 'SDR (RTL-SDR / AirSpy)',
fmBandwidths: [
{ value: 0, label: 'Auto' },
{ value: 4000, label: '4 kHz' },
{ value: 8000, label: '8 kHz' },
{ value: 10000, label: '10 kHz' },
{ value: 20000, label: '20 kHz' },
{ value: 30000, label: '30 kHz' },
{ value: 50000, label: '50 kHz' },
{ value: 75000, label: '75 kHz' },
{ value: 100000, label: '100 kHz' },
{ value: 125000, label: '125 kHz' },
{ value: 150000, label: '150 kHz' },
{ value: 175000, label: '175 kHz' },
{ value: 200000, label: '200 kHz' },
{ value: 225000, label: '225 kHz' }
],
details: ''
},
{
id: 'si47xx',
label: 'Si47XX (Si4735 / Si4732)',
fmBandwidths: [
{ value: 0, label: 'Auto' },
{ value: 40000, label: '40 kHz' },
{ value: 60000, label: '60 kHz' },
{ value: 84000, label: '84 kHz' },
{ value: 110000, label: '110 kHz' }
],
details: ''
}
];
module.exports = tunerProfiles;

View File

@@ -15,19 +15,15 @@ const fileExists = path => new Promise(resolve => fs.access(path, fs.constants.F
async function connect() {
if (serverConfig.tunnel?.enabled === true) {
const librariesDir = path.resolve(__dirname, '../libraries');
if (!await fileExists(librariesDir)) {
await fs.mkdir(librariesDir);
}
if (!await fileExists(librariesDir)) await fs.mkdir(librariesDir);
const frpcPath = path.resolve(librariesDir, 'frpc' + (os.platform() === 'win32' ? '.exe' : ''));
if (!await fileExists(frpcPath)) {
logInfo('frpc binary required for tunnel is not available. Downloading now...');
logInfo('frpc binary, required for tunnel is not available. Downloading now...');
const frpcFileName = `frpc_${os.platform}_${os.arch}` + (os.platform() === 'win32' ? '.exe' : '');
try {
const res = await fetch('https://fmtuner.org/binaries/' + frpcFileName);
if (res.status === 404) {
throw new Error('404 error');
}
if (res.status === 404) throw new Error('404 error');
const stream = fs2.createWriteStream(frpcPath);
await finished(Readable.fromWeb(res.body).pipe(stream));
} catch (err) {
@@ -35,13 +31,11 @@ async function connect() {
return;
}
logInfo('Downloading of frpc is completed.')
if (os.platform() === 'linux' || os.platform() === 'darwin') {
await fs.chmod(frpcPath, 0o770);
}
if (os.platform() === 'linux' || os.platform() === 'darwin') await fs.chmod(frpcPath, 0o770);
}
const cfg = ejs.render(frpcConfigTemplate, {
cfg: serverConfig.tunnel,
host: serverConfig.tunnel.community.enabled ? serverConfig.tunnel.community.host : serverConfig.tunnel.region + ".fmtuner.org",
host: serverConfig.tunnel.community.enabled ? serverConfig.tunnel.community.host : ((serverConfig.tunnel.region == "pldx") ? "pldx.duckdns.org" : (serverConfig.tunnel.region + ".fmtuner.org")),
server: {
port: serverConfig.webserver.webserverPort
}
@@ -62,15 +56,10 @@ async function connect() {
if (line.includes('connect to server error')) {
const reason = line.substring(line.indexOf(': ')+2);
logError('Failed to connect to tunnel, reason: ' + reason);
} else if (line.includes('invalid user or token')) {
logError('Failed to connect to tunnel, reason: invalid user or token');
} else if (line.includes('start proxy success')) {
logInfo('Tunnel established successfully');
} else if (line.includes('login to server success')) {
logInfo('Connection to tunnel server was successful');
} else {
logDebug('Tunnel log:', line);
}
} else if (line.includes('invalid user or token')) logError('Failed to connect to tunnel, reason: invalid user or token');
else if (line.includes('start proxy success')) logInfo('Tunnel established successfully');
else if (line.includes('login to server success')) logInfo('Connection to tunnel server was successful');
else logDebug('Tunnel log:', line);
});
child.on('error', (err) => {
@@ -80,7 +69,6 @@ async function connect() {
child.on('close', (code) => {
logInfo(`Tunnel process exited with code ${code}`);
});
}
}

View File

@@ -3,6 +3,9 @@ const { serverConfig } = require('./server_config');
const consoleCmd = require('./console');
let localDb = {};
let nextLocalDbUpdate = 0;
const localDbUpdateInterval = 7 * 24 * 60 * 60 * 1000; // 7-day database update interval
let awaitingTxInfo = true;
let lastFetchTime = 0;
let piFreqIndex = {}; // Indexing for speedier PI+Freq combinations
const fetchInterval = 1000;
@@ -71,20 +74,19 @@ if (serverConfig.identification.gpsMode) {
// Function to build local TX database from FMDX Maps endpoint.
async function buildTxDatabase() {
if (Latitude.length > 0 && Longitude.length > 0) {
let awaitingTxInfo = true;
awaitingTxInfo = true;
while (awaitingTxInfo) {
try {
consoleCmd.logInfo('Fetching transmitter database...');
const response = await fetch(`https://maps.fmdx.org/api?qth=${serverConfig.identification.lat},${serverConfig.identification.lon}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
headers: {'Accept': 'application/json'}
});
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
localDb = await response.json();
buildPiFreqIndex();
consoleCmd.logInfo('Transmitter database successfully loaded.');
nextLocalDbUpdate = Date.now() + localDbUpdateInterval;
awaitingTxInfo = false;
} catch (error) {
consoleCmd.logError("Failed to fetch transmitter database:", error);
@@ -92,9 +94,7 @@ async function buildTxDatabase() {
consoleCmd.logInfo('Retrying transmitter database download...');
}
}
} else {
consoleCmd.logInfo('Server latitude and longitude must be set before transmitter database can be built');
}
} else consoleCmd.logInfo('Server latitude and longitude must be set before transmitter database can be built');
}
// Function to build index map of PI+Freq combinations
@@ -158,8 +158,7 @@ function getStateBoundingBox(coordinates) {
// Function to check if a city (lat, lon) falls within the bounding box of a state
function isCityInState(lat, lon, boundingBox) {
return lat >= boundingBox.minLat && lat <= boundingBox.maxLat &&
lon >= boundingBox.minLon && lon <= boundingBox.maxLon;
return lat >= boundingBox.minLat && lat <= boundingBox.maxLat && lon >= boundingBox.minLon && lon <= boundingBox.maxLon;
}
// Function to check if a city (lat, lon) is inside any US state and return the state name
@@ -168,9 +167,7 @@ function getStateForCoordinates(lat, lon) {
for (const feature of usStatesGeoJson.features) {
const boundingBox = getStateBoundingBox(feature.geometry.coordinates);
if (isCityInState(lat, lon, boundingBox)) {
return feature.properties.name; // Return the state's name if city is inside bounding box
}
if (isCityInState(lat, lon, boundingBox)) return feature.properties.name; // Return the state's name if city is inside bounding box
}
return null;
}
@@ -185,7 +182,7 @@ function getStateForCoordinates(lat, lon) {
*/
function validPsCompare(rdsPs, stationPs) {
if (typeof stationPs !== 'string' || typeof rdsPs !== 'string') {
consoleCmd.logError(`Invalid TX values. stationPs: ${stationPs}, rdsPs: ${rdsPs}`);
consoleCmd.logDebug(`Invalid TX values. stationPs: ${stationPs}, rdsPs: ${rdsPs}`);
return false;
}
@@ -207,31 +204,22 @@ function validPsCompare(rdsPs, stationPs) {
for (let i = 0; i < standardizedRdsPs.length; i++) {
// Skip this position if the character in standardizedRdsPs is an underscore.
if (standardizedRdsPs[i] === '_') continue;
if (token[i] === standardizedRdsPs[i]) {
matchCount++;
}
}
if (matchCount >= minMatchLen) {
return true;
if (token[i] === standardizedRdsPs[i]) matchCount++;
}
if (matchCount >= minMatchLen) return true;
}
return false;
}
function evaluateStation(station, esMode) {
let weightDistance = station.distanceKm;
if (esMode && station.distanceKm > 700) {
weightDistance = Math.abs(station.distanceKm - 1500) + 200;
}
if (esMode && station.distanceKm > 700) weightDistance = Math.abs(station.distanceKm - 1500) + 200;
let erp = station.erp && station.erp > 0 ? station.erp : 1;
let extraWeight = erp > weightedErp && station.distanceKm <= weightDistance ? 0.3 : 0;
let score = 0;
// If ERP is 1W, use a simpler formula to avoid zero-scoring.
if (erp === 0.001) {
score = erp / station.distanceKm;
} else {
score = ((10 * (Math.log10(erp * 1000))) / weightDistance) + extraWeight;
}
if (erp === 0.001) score = erp / station.distanceKm;
else score = ((10 * (Math.log10(erp * 1000))) / weightDistance) + extraWeight;
return score;
}
@@ -240,6 +228,10 @@ async function fetchTx(freq, piCode, rdsPs) {
let match = null;
let multiMatches = [];
const now = Date.now();
if (now > nextLocalDbUpdate && !awaitingTxInfo) {
consoleCmd.logInfo('Time to update transmitter database.');
buildTxDatabase();
}
freq = parseFloat(freq);
if (
@@ -272,6 +264,15 @@ async function fetchTx(freq, piCode, rdsPs) {
stations: locData.stations.filter(station => validPsCompare(rdsPs, station.ps))
})).filter(locData => locData.stations.length > 0);
}
if (filteredLocations.length > 1) {
const extraFilteredLocations = filteredLocations.map(locData => ({
...locData,
stations: locData.stations.filter(station => (station.ps?.toLowerCase() === rdsPs.replace(/ /g, '_').toLowerCase()))
})).filter(locData => locData.stations.length > 0);
if (extraFilteredLocations.length > 0) filteredLocations = extraFilteredLocations;
}
for (let loc of filteredLocations) {
loc = Object.assign(loc, loc.stations[0]);
@@ -287,12 +288,8 @@ async function fetchTx(freq, piCode, rdsPs) {
loc => loc.distanceKm < 700 && loc.erp >= 10
);
let esMode = false;
if (!tropoPriority) {
esMode = checkEs();
}
for (let loc of filteredLocations) {
loc.score = evaluateStation(loc, esMode);
}
if (!tropoPriority) esMode = checkEs();
for (let loc of filteredLocations) loc.score = evaluateStation(loc, esMode);
// Sort by score in descending order
filteredLocations.sort((a, b) => b.score - a.score);
match = filteredLocations[0];
@@ -306,11 +303,9 @@ async function fetchTx(freq, piCode, rdsPs) {
}
if (match) {
if (match.itu === 'USA') {
if (match.itu == 'USA') { // Also known as Dumbfuckinstan. they should not go to hell, but hell+ (it is NOT better)
const state = getStateForCoordinates(match.lat, match.lon);
if (state) {
match.state = state; // Add state to matchingCity
}
if (state) match.state = state; // Add state to matchingCity
}
const result = {
station: match.detectedByPireg
@@ -343,9 +338,7 @@ function checkEs() {
const now = Date.now();
const url = "https://fmdx.org/includes/tools/get_muf.php";
if (esSwitchCache.lastCheck && now - esSwitchCache.lastCheck < esFetchInterval) {
return esSwitchCache.esSwitch;
}
if (esSwitchCache.lastCheck && now - esSwitchCache.lastCheck < esFetchInterval) return esSwitchCache.esSwitch;
if (Latitude > 20) {
esSwitchCache.lastCheck = now;
@@ -372,15 +365,12 @@ function haversine(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = deg2rad(lat2 - lat1);
const dLon = deg2rad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
const y = Math.sin(dLon) * Math.cos(deg2rad(lat2));
const x = Math.cos(deg2rad(lat1)) * Math.sin(deg2rad(lat2)) -
Math.sin(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.cos(dLon);
const x = Math.cos(deg2rad(lat1)) * Math.sin(deg2rad(lat2)) - Math.sin(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.cos(dLon);
const azimuth = Math.atan2(y, x);
const azimuthDegrees = (azimuth * 180 / Math.PI + 360) % 360;
@@ -394,6 +384,4 @@ function deg2rad(deg) {
return deg * (Math.PI / 180);
}
module.exports = {
fetchTx
};
module.exports = fetchTx;

View File

@@ -3,8 +3,11 @@
<head>
<title>Unauthorized - FM-DX Webserver</title>
<link href="css/entry.css" type="text/css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" type="text/css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" type="text/css"
rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"
integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link rel="icon" type="image/png" href="favicon2.png" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
@@ -13,16 +16,20 @@
<div class="panel-100 no-bg">
<img class="top-10" src="./images/openradio_logo_neutral.png" height="64px">
<h2 class="text-monospace text-light text-center">[403]</h2>
<div class="panel-100 p-10">
<br>
<i class="text-big fa-solid fa-exclamation-triangle color-4"></i>
<p>
There's a possibility you were kicked by the system.<br>
Please try again later.</p>
</div>
<% if (reason) { %>
<p><strong>Reason:</strong> too dig of a bick</p>
<% } %>
</div>
</div>
</div>
<script src="js/settings.js"></script>
</body>
</html>
</html>

View File

@@ -1,62 +1,12 @@
<%
let options = [];
if (device === 'tef') {
options = [
{ value: 0, label: 'Auto' },
{ value: 56000, label: '56 kHz' },
{ value: 64000, label: '64 kHz' },
{ value: 72000, label: '72 kHz' },
{ value: 84000, label: '84 kHz' },
{ value: 97000, label: '97 kHz' },
{ value: 114000, label: '114 kHz' },
{ value: 133000, label: '133 kHz' },
{ value: 151000, label: '151 kHz' },
{ value: 184000, label: '184 kHz' },
{ value: 200000, label: '200 kHz' },
{ value: 217000, label: '217 kHz' },
{ value: 236000, label: '236 kHz' },
{ value: 254000, label: '254 kHz' },
{ value: 287000, label: '287 kHz' },
{ value: 311000, label: '311 kHz' }
];
} else if (device === 'xdr') {
options = [
{ value: 0, value2: -1, label: 'Auto' },
{ value: 55000, value2: 0, label: '55 kHz' },
{ value: 73000, value2: 1, label: '73 kHz' },
{ value: 90000, value2: 2, label: '90 kHz' },
{ value: 108000, value2: 3, label: '108 kHz' },
{ value: 125000, value2: 4, label: '125 kHz' },
{ value: 142000, value2: 5, label: '142 kHz' },
{ value: 159000, value2: 6, label: '159 kHz' },
{ value: 177000, value2: 7, label: '177 kHz' },
{ value: 194000, value2: 8, label: '194 kHz' },
{ value: 211000, value2: 9, label: '211 kHz' },
{ value: 229000, value2: 10, label: '229 kHz' },
{ value: 246000, value2: 11, label: '246 kHz' },
{ value: 263000, value2: 12, label: '263 kHz' },
{ value: 281000, value2: 13, label: '281 kHz' },
{ value: 298000, value2: 14, label: '298 kHz' },
{ value: 309000, value2: 15, label: '309 kHz' }
];
} else if (device === 'sdr') {
options = [
{ value: 0, label: 'Auto' },
{ value: 4000, label: '4 kHz' },
{ value: 8000, label: '8 kHz' },
{ value: 10000, label: '10 kHz' },
{ value: 20000, label: '20 kHz' },
{ value: 30000, label: '30 kHz' },
{ value: 50000, label: '50 kHz' },
{ value: 75000, label: '75 kHz' },
{ value: 100000, label: '100 kHz' },
{ value: 125000, label: '125 kHz' },
{ value: 150000, label: '150 kHz' },
{ value: 175000, label: '175 kHz' },
{ value: 200000, label: '200 kHz' },
{ value: 225000, label: '225 kHz' }
];
const profile = Array.isArray(tunerProfiles)
? tunerProfiles.find((item) => item.id === device)
: null;
if (Array.isArray(profile?.fmBandwidths)) {
options = profile.fmBandwidths;
}
%>

View File

@@ -293,8 +293,8 @@ pre {
top: 10px;
}
.flex-container.contains-dropdown {
z-index: 999;
.contains-dropdown {
z-index: 990;
position: relative;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

24
web/favicon.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg width="128" height="128" viewBox="0 0 128 128"
xmlns="http://www.w3.org/2000/svg">
<!-- Outer hollow circle -->
<circle
cx="64"
cy="64"
r="54"
fill="none"
stroke="#A7A88B"
stroke-width="20"
/>
<!-- Inner hollow circle -->
<circle
cx="64"
cy="64"
r="22"
fill="none"
stroke="#FFFFFF"
stroke-width="18"
/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@@ -15,7 +15,7 @@
<script src="js/libs/chartjs-adapter-luxon.umd.min.js"></script>
<script src="js/libs/chartjs-plugin-streaming.min.js"></script>
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta property="og:title" content="FM-DX WebServer [<%= tunerName %>]">
@@ -94,6 +94,7 @@
<% if (device == 'tef') { %>TEF668x<% } %>
<% if (device == 'xdr') { %>Sony XDR<% } %>
<% if (device == 'sdr') { %>SDR<% } %>
<% if (device == 'si47xx') { %>SI47XX<% } %>
</span>
</div>
<div class="color-3 m-10 text-medium">
@@ -228,6 +229,17 @@
<div class="panel-50 no-bg br-0 h-100 m-0 button-eq">
<% if (device == 'tef') { %><button style="border-radius: 15px 0px 0px 15px;" class="data-eq hide-phone tooltip" aria-label="EQ Filter" data-tooltip="<strong>The cEQ filter can reduce bandwidth below 56 kHz.</strong><br><br>Useful for weak stations next to strong ones,<br>although it may pick up more interference."><span class="text-bold">cEQ</span></button><% } %>
<% if (device == 'xdr') { %><button style="border-radius: 15px 0px 0px 15px;" class="data-eq hide-phone tooltip" aria-label="RF+ Filter" data-tooltip="<strong>The RF+ filter increases gain by 5dB</strong>"><span class="text-bold">RF+</span></button><% } %>
<% if (device == 'si47xx' && si47xxAgcControl) { %>
<div class="no-bg dropdown dropdown-up data-agc hide-phone w-150" id="data-agc" style="margin-right: 15px !important;">
<input type="text" placeholder="AGC" readonly tabindex="0">
<ul class="options open-top" tabindex="-1">
<li data-value="0" class="option" tabindex="0">Auto AGC</li>
<li data-value="1" class="option" tabindex="0">High</li>
<li data-value="3" class="option" tabindex="0">Medium</li>
<li data-value="2" class="option" tabindex="0">Low</li>
</ul>
</div>
<% } %>
</div>
<div class="panel-50 no-bg br-0 h-100 m-0 button-ims">
<% if (device == 'tef') { %><button style="border-radius: 0px 15px 15px 0px;" class="data-ims hide-phone tooltip" aria-label="iMS + Filter" data-tooltip="<strong>The iMS filter reduces multipath audio artifacts.</strong><br><br>It's recommended to leave it on most of the time."><span class="text-bold">iMS</span></button><% } %>
@@ -248,8 +260,8 @@
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="1" aria-label="Volume slider">
</span>
<% if (bwSwitch) { %>
<%- include('_bwSwitch', { device: device, id: 'data-bw', cssClass: 'panel-50 dropdown-up m-0 w-150 m-left-15', cssClassOptions: 'open-top' }) %>
<% } %>
<%- include('_bwSwitch', { device: device, tunerProfiles: tunerProfiles, id: 'data-bw', cssClass: 'panel-50 dropdown-up m-0 w-150 m-left-15', cssClassOptions: 'open-top' }) %>
<% } %>
<% if (fmlist_integration == true && (fmlist_adminOnly == false || isTuneAuthenticated)) { %>
<button class="tooltip bg-color-4 mini-popup log-fmlist" data-tooltip="<strong>LOG TO FMLIST</strong><br>Clicking this button logs the current station to FMLIST's visual logbook." aria-label="Log to FMLIST" style="width: 80px; height: 48px;margin-left: 15px !important;">
<i class="fa-solid fa-flag fa-lg"></i>
@@ -353,16 +365,27 @@
<div class="flex-phone">
<% if (bwSwitch) { %>
<div style="max-height: 48px;width: 50%;margin-right: 5px;">
<%- include('_bwSwitch', { device: device, id: 'data-bw-phone', cssClass: 'panel-100-real', cssClassOptions: 'text-center open-bottom' }) %>
<%- include('_bwSwitch', { device: device, tunerProfiles: tunerProfiles, id: 'data-bw-phone', cssClass: 'panel-100-real', cssClassOptions: 'text-center open-bottom' }) %>
</div>
<% } %>
<% } %>
</div>
<p class="flex-phone flex-center">Filters</p>
<div class="flex-container flex-phone flex-center">
<% if (device == 'tef') { %><button class="data-eq tooltip p-10 m-right-5" style="height: 48px" aria-label="EQ Filter" data-tooltip="<strong>The cEQ filter can reduce bandwidth below 56 kHz.</strong><br><br>Useful for weak stations next to strong ones,<br>although it may pick up more interference."><span class="text-bold">cEQ</span></button><% } %>
<% if (device == 'xdr') { %><button class="data-eq tooltip p-10 m-right-5" aria-label="RF+ Filter" data-tooltip="<strong>The RF+ filter increases gain by 5dB</strong>"><span class="text-bold">RF+</span></button><% } %>
<% if (device == 'si47xx' && si47xxAgcControl) { %>
<div class="no-bg dropdown data-agc w-150" id="data-agc-phone" style="max-height: 48px;">
<input type="text" placeholder="AGC" readonly tabindex="0" style="border-radius: 15px;">
<ul class="options open-top" tabindex="-1">
<li data-value="0" class="option" tabindex="0">Auto AGC</li>
<li data-value="1" class="option" tabindex="0">High</li>
<li data-value="3" class="option" tabindex="0">Medium</li>
<li data-value="2" class="option" tabindex="0">Low</li>
</ul>
</div>
<% } %>
<% if (device == 'tef') { %><button class="data-ims tooltip p-10 m-left-5" style="height: 48px;" aria-label="iMS + Filter" data-tooltip="<strong>The iMS filter reduces multipath audio artifacts.</strong><br><br>It's recommended to leave it on most of the time."><span class="text-bold">iMS</span></button><% } %>
<% if (device == 'xdr') { %><button class="data-ims tooltip p-10 m-left-5" aria-label="IF+ Filter" data-tooltip="<strong>The IF+ filter increases gain by 6dB</strong>"><span class="text-bold">IF+</span></button><% } %>
</div>
@@ -422,14 +445,14 @@
<div style="width: calc(50% - 32px);text-align: center;">
<button class="users-online-container" aria-label="Online users" style="display: inline-block;"><i class="fa-solid fa-user"></i> <span class="users-online"></span></button>
<% if (chatEnabled) { %>
<button class="chatbutton m-10" aria-label="Chatbox" style="display: inline-block;"><i class="fa-solid fa-message"></i></button>
<% } %>
</div>
<div style="width: 64px;text-align: center;">
</div>
<div style="width: calc(50% - 32px);text-align: center;">
@@ -446,7 +469,7 @@
<h1 class="top-25">Settings</h1>
<div class="panel-full flex-center no-bg m-0">
<%- include('_components', { component: 'dropdown', id: 'theme-selector', inputId: 'theme-selector-input', label: 'Theme', cssClass: '', placeholder: 'Default',
<%- include('_components', { component: 'dropdown', id: 'theme-selector', inputId: 'theme-selector-input', label: 'Theme', cssClass: '', placeholder: 'Default',
options: [
{ value: 'theme1', label: 'Mint' },
{ value: 'theme2', label: 'Cappuccino' },
@@ -463,7 +486,7 @@
<% if (device !== 'sdr') { %>
<div class="panel-full flex-center no-bg m-0">
<%- include('_components', { component: 'dropdown', id: 'signal-selector', inputId: 'signal-selector-input', label: 'Signal units', cssClass: '', placeholder: 'dBf',
<%- include('_components', { component: 'dropdown', id: 'signal-selector', inputId: 'signal-selector-input', label: 'Signal units', cssClass: '', placeholder: 'dBf',
options: [
{ value: 'dbf', label: 'dBf' },
{ value: 'dbuv', label: 'dBuV' },

View File

@@ -27,11 +27,8 @@ function destroyStream() {
function OnConnectivityCallback(isConnected) {
console.log("Connectivity changed:", isConnected);
if (Stream) {
Stream.Volume = $('#volumeSlider').val();
} else {
console.warn("Stream is not initialized.");
}
if (Stream) Stream.Volume = $('#volumeSlider').val();
else console.warn("Stream is not initialized.");
}
@@ -44,18 +41,14 @@ function OnPlayButtonClick(_ev) {
shouldReconnect = false;
destroyStream();
$playbutton.find('.fa-solid').toggleClass('fa-stop fa-play');
if (isAppleiOS && 'audioSession' in navigator) {
navigator.audioSession.type = "none";
}
if (isAppleiOS && 'audioSession' in navigator) navigator.audioSession.type = "none";
} else {
console.log("Starting stream...");
shouldReconnect = true;
createStream();
Stream.Start();
$playbutton.find('.fa-solid').toggleClass('fa-play fa-stop');
if (isAppleiOS && 'audioSession' in navigator) {
navigator.audioSession.type = "playback";
}
if (isAppleiOS && 'audioSession' in navigator) navigator.audioSession.type = "playback";
}
$playbutton.addClass('bg-gray').prop('disabled', true);
@@ -70,9 +63,7 @@ function updateVolume() {
newVolumeGlobal = newVolume;
console.log("Volume updated to:", newVolume);
Stream.Volume = newVolume;
} else {
console.warn("Stream is not initialized.");
}
} else console.warn("Stream is not initialized.");
}
$(document).ready(Init);

View File

@@ -3,18 +3,13 @@ function tuneUp() {
if (socket.readyState === WebSocket.OPEN) {
getCurrentFreq();
let addVal = 0;
if (currentFreq < 0.52) {
addVal = 9 - (Math.round(currentFreq*1000) % 9);
} else if (currentFreq < 1.71) {
if (currentFreq < 0.52) addVal = 9 - (Math.round(currentFreq*1000) % 9);
else if (currentFreq < 1.71) {
// TODO: Rework to replace 9 with 9 or 10 based on regionalisation setting
addVal = 9 - (Math.round(currentFreq*1000) % 9);
} else if (currentFreq < 29.6) {
addVal = 5 - (Math.round(currentFreq*1000) % 5);
} else if (currentFreq >= 65.9 && currentFreq < 74) {
addVal = 30 - ((Math.round(currentFreq*1000) - 65900) % 30);
} else {
addVal = 100 - (Math.round(currentFreq*1000) % 100);
}
} else if (currentFreq < 29.6) addVal = 5 - (Math.round(currentFreq*1000) % 5);
else if (currentFreq >= 65.9 && currentFreq < 74) addVal = 30 - ((Math.round(currentFreq*1000) - 65900) % 30);
else addVal = 100 - (Math.round(currentFreq*1000) % 100);
socket.send("T" + (Math.round(currentFreq*1000) + addVal));
}
}
@@ -23,18 +18,13 @@ function tuneDown() {
if (socket.readyState === WebSocket.OPEN) {
getCurrentFreq();
let subVal = 0;
if (currentFreq < 0.52) {
if (currentFreq < 0.52) subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
else if (currentFreq < 1.71) {
// TODO: Rework to replace 9 with 9 or 10 based on regionalisation setting (Americans use 10, because of dumbfuckinstan)
subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
} else if (currentFreq < 1.71) {
// TODO: Rework to replace 9 with 9 or 10 based on regionalisation setting
subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
} else if (currentFreq < 29.6) {
subVal = (Math.round(currentFreq*1000) % 5 == 0) ? 5 : (Math.round(currentFreq*1000) % 5);
} else if (currentFreq > 65.9 && currentFreq <= 74) {
subVal = ((Math.round(currentFreq*1000) - 65900) % 30 == 0) ? 30 : ((Math.round(currentFreq*1000) - 65900) % 30);
} else {
subVal = (Math.round(currentFreq*1000) % 100 == 0) ? 100 : (Math.round(currentFreq*1000) % 100);
}
} else if (currentFreq < 29.6) subVal = (Math.round(currentFreq*1000) % 5 == 0) ? 5 : (Math.round(currentFreq*1000) % 5);
else if (currentFreq > 65.9 && currentFreq <= 74) subVal = ((Math.round(currentFreq*1000) - 65900) % 30 == 0) ? 30 : ((Math.round(currentFreq*1000) - 65900) % 30);
else subVal = (Math.round(currentFreq*1000) % 100 == 0) ? 100 : (Math.round(currentFreq*1000) % 100);
socket.send("T" + (Math.round(currentFreq*1000) - subVal));
}
}
@@ -52,6 +42,6 @@ function getCurrentFreq() {
currentFreq = $('#data-frequency').text();
currentFreq = parseFloat(currentFreq).toFixed(3);
currentFreq = parseFloat(currentFreq);
return currentFreq;
}

View File

@@ -9,42 +9,40 @@ $(document).ready(function() {
const chatIdentityNickname = $('#chat-identity-nickname');
const chatNicknameInput = $('#chat-nickname');
const chatNicknameSave = $('#chat-nickname-save');
$(".chatbutton").on("click", function () {
togglePopup("#popup-panel-chat");
chatMessages.scrollTop(chatMessages[0].scrollHeight);
});
// Function to generate a random string
function generateRandomString(length) {
const characters = 'ABCDEFGHJKMNOPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
for (let i = 0; i < length; i++) result += characters.charAt(Math.floor(Math.random() * characters.length));
return result;
}
// Load nickname from localStorage on page load
let savedNickname = localStorage.getItem('nickname') || `User ${generateRandomString(5)}`;
chatNicknameInput.val(savedNickname);
chatIdentityNickname.text(savedNickname);
chatSocket.onmessage = function(event) {
const messageData = JSON.parse(event.data);
const isAdmin = messageData.admin ? '<span style="color: #bada55">[ADMIN]</span>' : '';
if (messageData.type === 'clientIp') {
chatIdentityNickname.html(isAdmin).append(document.createTextNode(" " + savedNickname));
chatIdentityNickname.attr('title', messageData.ip);
} else {
const chatMessage = `
<span class="color-2">[${messageData.time}]</span>
${isAdmin} <strong class="color-5" title="${typeof messageData.ip !== "undefined" ? 'IP Address: ' + messageData.ip : ''}">${messageData.nickname}</strong>:
${isAdmin} <strong class="color-5" title="${typeof messageData.ip !== "undefined" ? 'IP Address: ' + messageData.ip : ''}">${messageData.nickname}</strong>:
<span style="color: var(--color-text-2);">${$('<div/>').text(messageData.message).html()}</span><br>
`;
chatMessages.append(chatMessage);
if (chatMessages.is(':visible')) {
setTimeout(function() {
chatMessages.scrollTop(chatMessages[0].scrollHeight);
@@ -59,7 +57,7 @@ $(document).ready(function() {
}
}
};
$('.chat-send-message-btn').click(sendMessage);
chatNicknameSave.click(function() {
const currentNickname = chatNicknameInput.val().trim() || `Anonymous User ${generateRandomString(5)}`;
@@ -68,34 +66,32 @@ $(document).ready(function() {
chatIdentityNickname.text(savedNickname);
chatNicknameInput.blur();
});
chatButton.click(function() {
chatMessageCount = 0;
chatMessagesCount.text(chatMessageCount);
chatButton.removeClass('blink').addClass('bg-color-1');
chatSendInput.focus();
setTimeout(function() {
chatMessages.scrollTop(chatMessages[0].scrollHeight);
}, 100);
});
chatNicknameInput.keypress(function(event) {
if (event.which === 13) {
chatNicknameSave.trigger('click');
}
});
chatSendInput.keypress(function(event) {
if (event.which === 13) {
sendMessage();
}
if (event.which === 13) sendMessage();
});
function sendMessage() {
const nickname = savedNickname || `Anonymous User ${generateRandomString(5)}`;
const message = chatSendInput.val().trim();
if (message) {
const messageData = { nickname, message };
chatSocket.send(JSON.stringify(messageData));

View File

@@ -28,7 +28,7 @@ function submitConfig() {
function fetchConfig() {
$.getJSON("./getData")
.done(data => {
configData = data;
configData = data;
populateFields(configData);
initVolumeSlider();
initConnectionToggle();
@@ -38,9 +38,7 @@ function fetchConfig() {
function populateFields(data, prefix = "") {
$.each(data, (key, value) => {
if (value === null) {
value = ""; // Convert null to an empty string
}
if (value === null) value = ""; // Convert null to an empty string
let id = `${prefix}${prefix ? "-" : ""}${key}`;
const $element = $(`#${id}`);
@@ -50,16 +48,13 @@ function populateFields(data, prefix = "") {
$element.find('option').each(function() {
const $option = $(this);
const dataName = $option.data('name');
if (value.includes(dataName)) {
$option.prop('selected', true);
} else {
$option.prop('selected', false);
}
if (value.includes(dataName)) $option.prop('selected', true);
else $option.prop('selected', false);
});
$element.trigger('change');
}
return;
return;
}
if (typeof value === "object" && value !== null) {
@@ -68,11 +63,8 @@ function populateFields(data, prefix = "") {
const arrayId = `${id}-${index + 1}`;
const $arrayElement = $(`#${arrayId}`);
if ($arrayElement.length) {
$arrayElement.val(item);
} else {
console.log(`Element with id ${arrayId} not found`);
}
if ($arrayElement.length) $arrayElement.val(item);
else console.log(`Element with id ${arrayId} not found`);
});
return;
} else {
@@ -92,9 +84,7 @@ function populateFields(data, prefix = "") {
const $dropdownOption = $element.siblings('ul.options').find(`li[data-value="${value}"]`);
$element.val($dropdownOption.length ? $dropdownOption.text() : value);
$element.attr('data-value', value);
} else {
$element.val(value);
}
} else $element.val(value);
});
}
@@ -111,9 +101,7 @@ function updateConfigData(data, prefix = "") {
if ($presetElement.length) {
data[key].push($presetElement.val() || null); // Allow null if necessary
index++;
} else {
break;
}
} else break;
}
return;
}
@@ -123,16 +111,12 @@ function updateConfigData(data, prefix = "") {
const $selectedOptions = $element.find('option:selected');
$selectedOptions.each(function() {
const dataName = $(this).attr('data-name');
if (dataName) {
data[key].push(dataName);
}
if (dataName) data[key].push(dataName);
});
return;
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return updateConfigData(value, id);
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) return updateConfigData(value, id);
if ($element.length) {
const newValue = $element.attr("data-value") ?? $element.val() ?? null;

View File

@@ -23,30 +23,38 @@ $(document).ready(function() {
switch($currentDropdown.attr('id')) {
case 'data-ant':
socket.send("Z" + $(event.currentTarget).attr('data-value'));
resetRDS(getCurrentFreq()); // Reset RDS when change antenna input
break;
socket.send("Z" + $(event.currentTarget).attr('data-value'));
resetRDS(getCurrentFreq()); // Reset RDS when change antenna input
break;
case 'data-ant-phone':
socket.send("Z" + $(event.currentTarget).attr('data-value'));
resetRDS(getCurrentFreq()); // Reset RDS when change antenna input
break;
socket.send("Z" + $(event.currentTarget).attr('data-value'));
resetRDS(getCurrentFreq()); // Reset RDS when change antenna input
break;
case 'data-bw':
legacyBwValue = $(event.currentTarget).attr('data-value2') || "";
socket.send("F" + legacyBwValue);
socket.send("W" + $(event.currentTarget).attr('data-value'));
$currentDropdown.find('input').val($(event.currentTarget).text());
break;
legacyBwValue = $(event.currentTarget).attr('data-value2') || "";
socket.send("F" + legacyBwValue);
socket.send("W" + $(event.currentTarget).attr('data-value'));
$currentDropdown.find('input').val($(event.currentTarget).text());
break;
case 'data-bw-phone':
legacyBwValue = $(event.currentTarget).attr('data-value2') || "";
socket.send("F" + legacyBwValue);
socket.send("W" + $(event.currentTarget).attr('data-value'));
$currentDropdown.find('input').val($(event.currentTarget).text());
break;
legacyBwValue = $(event.currentTarget).attr('data-value2') || "";
socket.send("F" + legacyBwValue);
socket.send("W" + $(event.currentTarget).attr('data-value'));
$currentDropdown.find('input').val($(event.currentTarget).text());
break;
case 'data-agc':
socket.send("A" + $(event.currentTarget).attr('data-value'));
$currentDropdown.find('input').val($(event.currentTarget).text());
break;
case 'data-agc-phone':
socket.send("A" + $(event.currentTarget).attr('data-value'));
$currentDropdown.find('input').val($(event.currentTarget).text());
break;
default:
$currentDropdown.find('input')
.val($(event.currentTarget).text())
.attr('data-value', $(event.currentTarget).data('value'));
break;
$currentDropdown.find('input')
.val($(event.currentTarget).text())
.attr('data-value', $(event.currentTarget).data('value'));
break;
}
// Use setTimeout to delay class removal
@@ -72,24 +80,24 @@ $(document).ready(function() {
const $options = currentDropdown.find('.option');
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
currentIndex = (currentIndex + 1) % $options.length;
$options.eq(currentIndex).focus();
break;
event.preventDefault();
currentIndex = (currentIndex + 1) % $options.length;
$options.eq(currentIndex).focus();
break;
case 'ArrowUp':
event.preventDefault();
currentIndex = (currentIndex - 1 + $options.length) % $options.length;
$options.eq(currentIndex).focus();
break;
event.preventDefault();
currentIndex = (currentIndex - 1 + $options.length) % $options.length;
$options.eq(currentIndex).focus();
break;
case 'Enter':
event.preventDefault();
$options.eq(currentIndex).click();
break;
event.preventDefault();
$options.eq(currentIndex).click();
break;
case 'Escape':
currentDropdown.removeClass('opened');
currentDropdown = null;
currentIndex = -1;
break;
currentDropdown.removeClass('opened');
currentDropdown = null;
currentIndex = -1;
break;
}
};
@@ -98,9 +106,7 @@ $(document).ready(function() {
$listOfOptions.on('click', selectOption);
$dropdowns.on('click', 'input', toggleDropdown);
$dropdowns.on('keydown', 'input', function(event) {
if (event.key === 'Enter') {
toggleDropdown(event);
}
if (event.key === 'Enter') toggleDropdown(event);
});
$dropdowns.on('keydown', '.option', navigateOptions);

View File

@@ -25,23 +25,23 @@ const europe_programmes = [
const usa_programmes = [
"No PTY", "News", "Information", "Sports", "Talk", "Rock", "Classic Rock",
"Adults Hits", "Soft Rock", "Top 40", "Country", "Oldies", "Soft Music",
"Nostalgia", "Jazz", "Classical", "Rhythm and Blues", "Soft Rhythm and Blues",
"Nostalgia", "Jazz", "Classical", "Rhythm and Blues", "Soft Rhythm and Blues",
"Language", "Religious Music", "Religious Talk", "Personality", "Public", "College",
"Spanish Talk", "Spanish Music", "Hip Hop", "", "", "Weather", "Emergency Test", "Emergency"
"Spanish Talk", "Spanish Music", "Hip Hop", "", "", "Weather", "Emergency Test", "Emergency"
];
const rdsMode = localStorage.getItem('rdsMode');
$(document).ready(function () {
const signalToggle = $("#signal-units-toggle");
var $panel = $('.admin-quick-dashboard');
var panelWidth = $panel.outerWidth();
$(document).mousemove(function(e) {
var mouseX = e.pageX;
var panelLeft = parseInt($panel.css('left'));
if (mouseX <= 10 || (panelLeft === 4 && mouseX <= 100)) {
$panel.css('left', '4px');
} else {
@@ -50,10 +50,10 @@ $(document).ready(function () {
});
fillPresets();
signalToggle.on("change", function () {
const signalText = localStorage.getItem('signalUnit');
if (signalText == 'dbuv') {
signalText.text('dBµV');
} else if (signalText == 'dbf') {
@@ -62,7 +62,7 @@ $(document).ready(function () {
signalText.text('dBm');
}
});
// Check if device is an iPhone to prevent zoom on button press
if (/iPhone|iPod|iPad/.test(navigator.userAgent) && !window.MSStream) {
// Handle touchstart for buttons to prevent zoom
@@ -89,9 +89,9 @@ $(document).ready(function () {
$viewportMeta.attr('content', content);
}
}
const textInput = $('#commandinput');
textInput.on('change blur', function (event) {
const inputValue = Number(textInput.val());
// Check if the user agent contains 'iPhone'
@@ -101,18 +101,18 @@ $(document).ready(function () {
textInput.val('');
}
});
textInput.on('keyup', function (event) {
if (event.key !== 'Backspace' && localStorage.getItem('extendedFreqRange') != "true") {
let inputValue = textInput.val();
inputValue = inputValue.replace(/[^0-9.]/g, '');
if (inputValue.includes("..")) {
inputValue = inputValue.slice(0, inputValue.lastIndexOf('.')) + inputValue.slice(inputValue.lastIndexOf('.') + 1);
textInput.val(inputValue);
}
if (!inputValue.includes(".")) {
if (inputValue.startsWith('10') && inputValue.length > 2) {
inputValue = inputValue.slice(0, 3) + '.' + inputValue.slice(3);
@@ -130,31 +130,31 @@ $(document).ready(function () {
textInput.val('');
}
});
document.onkeydown = function(event) {
if (!event.repeat) {
checkKey(event);
}
};
let lastExecutionTime = 0;
const throttleDelay = 100; // Time in ms
$('#freq-container').on('wheel keypress', function (e) {
e.preventDefault();
const now = Date.now();
if (now - lastExecutionTime < throttleDelay) {
// Ignore this event as it's within the throttle delay
return;
}
lastExecutionTime = now; // Update the last execution time
getCurrentFreq();
var delta = e.originalEvent.deltaY;
var adjustment = 0;
if (e.shiftKey) {
adjustment = e.altKey ? 1 : 0.01;
} else if (e.ctrlKey) {
@@ -167,21 +167,21 @@ $(document).ready(function () {
}
return false;
}
var newFreq = currentFreq + (delta > 0 ? -adjustment : adjustment);
socket.send("T" + (Math.round(newFreq * 1000)));
return false;
});
setInterval(getServerTime, 10000);
getServerTime();
setInterval(sendPingRequest, 5000);
sendPingRequest();
$("#tuner-name").click(function() {
showTunerDescription();
});
var freqUpButton = $('#freq-up')[0];
var freqDownButton = $('#freq-down')[0];
var psContainer = $('#ps-container')[0];
@@ -189,19 +189,19 @@ $(document).ready(function () {
var piCodeContainer = $('#pi-code-container')[0];
var freqContainer = $('#freq-container')[0];
var txContainer = $('#data-station-container')[0];
$(".data-eq").click(function () {
toggleButtonState("eq");
});
$(".data-ims").click(function () {
toggleButtonState("ims");
});
$("#volumeSlider").on('mouseup', function() {
$('#volumeSlider').blur();
})
$(freqUpButton).on("click", tuneUp);
$(freqDownButton).on("click", tuneDown);
$(psContainer).on("click", copyPs);
@@ -212,37 +212,37 @@ $(document).ready(function () {
$(freqContainer).on("click", function () {
textInput.focus();
});
//FMLIST logging
$('.popup-content').on('click', function(event) {
event.stopPropagation();
$('.popup-content').removeClass('show');
});
$('.log-fmlist').on('click', function() {
const logKey = 'fmlistLogChoice';
const logTimestampKey = 'fmlistLogTimestamp';
const expirationTime = 10 * 60 * 1000;
const logKey = 'fmlistLogChoice';
const logTimestampKey = 'fmlistLogTimestamp';
const expirationTime = 10 * 60 * 1000;
const now = Date.now();
const storedChoice = localStorage.getItem(logKey);
const storedTimestamp = localStorage.getItem(logTimestampKey);
if (storedChoice && storedTimestamp && (now - storedTimestamp < expirationTime)) {
sendLog(storedChoice);
sendLog(storedChoice);
return;
}
if (parsedData.txInfo.dist > 700) {
$('.log-fmlist .mini-popup-content').addClass('show'); // Show popup if no valid choice
$('.log-fmlist-sporadice').off('click').on('click', function () {
localStorage.setItem(logKey, './log_fmlist?type=sporadice');
localStorage.setItem(logTimestampKey, now);
if(parsedData.txInfo.dist > 700) sendLog('./log_fmlist?type=sporadice');
$('.log-fmlist .mini-popup-content').removeClass('show');
});
$('.log-fmlist-tropo').off('click').on('click', function () {
localStorage.setItem(logKey, './log_fmlist?type=tropo');
localStorage.setItem(logTimestampKey, now);
@@ -250,9 +250,9 @@ $(document).ready(function () {
$('.log-fmlist .mini-popup-content').removeClass('show');
});
} else {
sendLog('./log_fmlist');
sendLog('./log_fmlist');
}
function sendLog(endpoint) {
$.ajax({
url: endpoint,
@@ -262,7 +262,7 @@ $(document).ready(function () {
},
error: function(xhr) {
let errorMessage;
switch (xhr.status) {
case 429:
errorMessage = xhr.responseText;
@@ -273,7 +273,7 @@ $(document).ready(function () {
default:
errorMessage = xhr.statusText || 'An error occurred';
}
sendToast('error', 'Log failed', errorMessage, false, true);
}
});
@@ -290,7 +290,7 @@ function getServerTime() {
dataType: "json",
success: function(data) {
const serverTimeUtc = data.serverTime;
const options = {
year: 'numeric',
month: 'short',
@@ -299,32 +299,32 @@ function getServerTime() {
minute: '2-digit',
hour12: false
};
const serverOptions = {
...options,
timeZone: 'Etc/UTC'
};
const formattedServerTime = new Date(serverTimeUtc).toLocaleString(navigator.language ? navigator.language : 'en-US', serverOptions);
$("#server-time").text(formattedServerTime);
$("#server-time").text(formattedServerTime);
},
error: function(jqXHR, textStatus, errorThrown) {
console.error("Error fetching server time:", errorThrown);
}
});
}
}
function sendPingRequest() {
const timeoutDuration = 5000;
const startTime = new Date().getTime();
const fetchWithTimeout = (url, options, timeout = timeoutDuration) => {
return new Promise((resolve, reject) => {
const timerTimeout = setTimeout(() => {
reject(new Error('Request timed out'));
}, timeout);
fetch(url, options)
.then(response => {
clearTimeout(timerTimeout);
@@ -336,7 +336,7 @@ function sendPingRequest() {
});
});
};
fetchWithTimeout('./ping', { cache: 'no-store' }, timeoutDuration)
.then(response => {
const endTime = new Date().getTime();
@@ -354,7 +354,7 @@ function sendPingRequest() {
pingTimeLimit = true;
}
});
function handleMessage(message) {
messageData = JSON.parse(message.data.length);
socket.removeEventListener('message', handleMessage);
@@ -362,7 +362,7 @@ function sendPingRequest() {
socket.addEventListener('message', handleMessage);
messageLength = messageData;
messageData = 0;
// Force reconnection if no WebSocket data after several queries
if (messageLength === 0) {
messageCounter++;
@@ -375,7 +375,7 @@ function sendPingRequest() {
} else {
messageCounter = 0;
}
// Automatic reconnection on WebSocket close with cooldown
const now = Date.now();
if (
@@ -421,12 +421,12 @@ function handleWebSocketMessage(event) {
}, 500);
return;
}
parsedData = JSON.parse(event.data);
resetDataTimeout();
updatePanels(parsedData);
const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0);
const averageSignal = sum / signalData.length;
data.push(averageSignal);
@@ -500,11 +500,11 @@ function initCanvas() {
beginAtZero: false,
grace: 0.25,
border: { display: false },
ticks: {
maxTicksLimit: 3,
ticks: {
maxTicksLimit: 3,
display: false // Hide default labels
},
grid: {
grid: {
display: false, // Hide default grid lines
},
},
@@ -513,11 +513,11 @@ function initCanvas() {
beginAtZero: false,
grace: 0.25,
border: { display: false },
ticks: {
maxTicksLimit: 3,
ticks: {
maxTicksLimit: 3,
display: false // Hide default labels for the right axis
},
grid: {
grid: {
display: false, // No grid for right axis
}
}
@@ -558,21 +558,21 @@ function initCanvas() {
case "dbm": adjustedTickValue = tick.value - 120; break;
default: adjustedTickValue = tick.value; break;
}
if (isMiddleTick) { adjustedY += 3; }
ctx.textAlign = 'right';
ctx.fillText(adjustedTickValue.toFixed(1), leftX + 25, adjustedY);
ctx.fillText(adjustedTickValue.toFixed(1), leftX + 25, adjustedY);
ctx.textAlign = 'left';
ctx.fillText(adjustedTickValue.toFixed(1), rightX - 25, adjustedY); // Right side
});
const gridLineWidth = 0.5; // Make the lines thinner to avoid overlapping text
const adjustedGridTop = chartArea.top + offset;
const adjustedGridBottom = chartArea.bottom - offset;
const middleY = chartArea.top + chartArea.height / 2;
const padding = 45; // 30px inward on both sides
// Helper function to draw a horizontal line
function drawGridLine(y) {
ctx.beginPath();
@@ -582,12 +582,12 @@ function initCanvas() {
ctx.lineWidth = gridLineWidth;
ctx.stroke();
}
// Draw the three horizontal grid lines
drawGridLine(adjustedGridTop);
drawGridLine(adjustedGridBottom);
drawGridLine(middleY);
ctx.restore();
}
}]
@@ -634,12 +634,12 @@ socket.onmessage = (event) => {
}, 500);
return;
}
parsedData = JSON.parse(event.data);
resetDataTimeout();
updatePanels(parsedData);
const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0);
const averageSignal = sum / signalData.length;
data.push(averageSignal);
@@ -661,7 +661,7 @@ function processString(string, errors) {
const alpha_range = 50;
const max_error = 10;
errors = errors?.split(',');
for (let i = 0; i < string.length; i++) {
alpha = parseInt(errors[i]) * (alpha_range / (max_error + 1));
if (alpha) {
@@ -670,27 +670,27 @@ function processString(string, errors) {
output += escapeHTML(string[i]);
}
}
return output;
}
function checkKey(e) {
e = e || window.event;
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
return;
}
if ($('#password:focus').length > 0
|| $('#chat-send-message:focus').length > 0
|| $('#volumeSlider:focus').length > 0
|| $('#chat-nickname:focus').length > 0
|| $('.option:focus').length > 0) {
return;
return;
}
getCurrentFreq();
if (socket.readyState === WebSocket.OPEN) {
switch (e.keyCode) {
case 66: // Back to previous frequency
@@ -717,9 +717,9 @@ function checkKey(e) {
let $dropdown = $(".data-ant");
let $input = $dropdown.find("input");
let $options = $dropdown.find("ul.options .option");
if ($options.length === 0) return; // No antennas available
// Find the currently selected antenna
let currentText = $input.val().trim();
let currentIndex = $options.index($options.filter(function () {
@@ -727,15 +727,15 @@ function checkKey(e) {
}));
console.log(currentIndex, currentText);
// Cycle to the next option
let nextIndex = (currentIndex + 1) % $options.length;
let $nextOption = $options.eq(nextIndex);
// Update UI
$input.attr("placeholder", $nextOption.text());
$input.data("value", $nextOption.data("value"));
let socketMessage = "Z" + $nextOption.data("value");
socket.send(socketMessage);
break;
@@ -769,7 +769,7 @@ async function copyPs() {
var signal = $('#data-signal').text();
var signalDecimal = $('#data-signal-decimal').text();
var signalUnit = $('.signal-units').eq(0).text();
try {
await copyToClipboard(frequency + " - " + pi + " | " + ps + " [" + signal + signalDecimal + " " + signalUnit + "]");
} catch (error) {
@@ -785,7 +785,7 @@ async function copyTx() {
const stationItu = $('#data-station-itu').text();
const stationDistance = $('#data-station-distance').text();
const stationErp = $('#data-station-erp').text();
try {
await copyToClipboard(frequency + " - " + pi + " | " + stationName + " [" + stationCity + ", " + stationItu + "] - " + stationDistance + " | " + stationErp + " kW");
} catch (error) {
@@ -796,7 +796,7 @@ async function copyTx() {
async function copyRt() {
var rt0 = $('#data-rt0 span').text();
var rt1 = $('#data-rt1 span').text();
try {
await copyToClipboard("[0] RT: " + rt0 + "\n[1] RT: " + rt1);
} catch (error) {
@@ -818,10 +818,10 @@ function copyToClipboard(textToCopy) {
'position': 'absolute',
'left': '-999999px'
});
$('body').prepend(textArea);
textArea.select();
try {
document.execCommand('copy');
} catch (error) {
@@ -837,9 +837,9 @@ function findOnMaps() {
var pi = $('#data-pi').text();
var latitude = localStorage.getItem('qthLongitude');
var longitude = localStorage.getItem('qthLatitude');
frequency > 74 ? frequency = frequency.toFixed(1) : null;
var url = `https://maps.fmdx.org/#qth=${longitude},${latitude}&freq=${frequency}&findPi=${pi}`;
window.open(url, "_blank");
}
@@ -849,33 +849,33 @@ function updateSignalUnits(parsedData, averageSignal) {
const signalUnit = localStorage.getItem('signalUnit');
let currentSignal;
let highestSignal = parsedData.sigTop;
currentSignal = averageSignal
let signalText = $('.signal-units');
let signalValue;
switch (signalUnit) {
case 'dbuv':
signalValue = currentSignal - 11.25;
highestSignal = highestSignal - 11.25;
signalText.text('dBµV');
break;
case 'dbm':
signalValue = currentSignal - 120;
highestSignal = highestSignal - 120;
signalText.text('dBm');
break;
default:
signalValue = currentSignal;
signalText.text('dBf');
break;
}
const formatted = (Math.round(signalValue * 10) / 10).toFixed(1);
const [integerPart, decimalPart] = formatted.split('.');
$('#data-signal-highest').text(Number(highestSignal).toFixed(1));
$('#data-signal').text(integerPart);
$('#data-signal-decimal').text('.' + decimalPart);
@@ -890,6 +890,7 @@ const $dataSt = $('.data-st');
const $dataRt0 = $('#data-rt0 span');
const $dataRt1 = $('#data-rt1 span');
const $dataAntInput = $('.data-ant input');
const $dataAgcInput = $('.data-agc input');
const $dataBwInput = $('.data-bw input');
const $dataStationContainer = $('#data-station-container');
const $dataTp = $('.data-tp');
@@ -901,17 +902,17 @@ const $dataPty = $('.data-pty');
// Throttling function to limit the frequency of updates
function throttle(fn, wait) {
let isThrottled = false, savedArgs, savedThis;
function wrapper() {
if (isThrottled) {
savedArgs = arguments;
savedThis = this;
return;
}
fn.apply(this, arguments);
isThrottled = true;
setTimeout(function() {
isThrottled = false;
if (savedArgs) {
@@ -920,7 +921,7 @@ function throttle(fn, wait) {
}
}, wait);
}
return wrapper;
}
@@ -967,18 +968,18 @@ const updateDataElements = throttle(function(parsedData) {
updateTextIfChanged($dataFrequency, parsedData.freq);
$commandInput.attr("aria-label", "Current frequency: " + parsedData.freq);
updateHtmlIfChanged($dataPi, parsedData.pi === '?' ? "<span class='opacity-half'>?</span>" : parsedData.pi);
if ($('#ps-underscores').is(':checked')) {
parsedData.ps = parsedData.ps.replace(/\s/g, '_');
}
updateHtmlIfChanged($dataPs, parsedData.ps === '?' ? "<span class='opacity-half'>?</span>" : processString(parsedData.ps, parsedData.ps_errors));
if(parsedData.st) {
$dataSt.parent().removeClass('opacity-half');
} else {
$dataSt.parent().addClass('opacity-half');
}
if(parsedData.stForced) {
if (!parsedData.st) {
stereoColor = 'gray';
@@ -991,29 +992,28 @@ const updateDataElements = throttle(function(parsedData) {
$('.data-st.circle1').css('left', '0px');
$('.data-st.circle2').css('display', 'block');
}
updateHtmlIfChanged($dataRt0, processString(parsedData.rt0, parsedData.rt0_errors));
updateHtmlIfChanged($dataRt1, processString(parsedData.rt1, parsedData.rt1_errors));
updateTextIfChanged($dataPty, rdsMode == 'true' ? usa_programmes[parsedData.pty] : europe_programmes[parsedData.pty]);
if (parsedData.rds === true) {
$flagDesktopCointainer.css('background-color', 'var(--color-2-transparent)');
} else {
$flagDesktopCointainer.css('background-color', 'var(--color-1-transparent)');
}
$('.data-flag').html(`<i title="${parsedData.country_name}" class="flag-sm flag-sm-${parsedData.country_iso}"></i>`);
$('.data-flag-big').html(`<i title="${parsedData.country_name}" class="flag-md flag-md-${parsedData.country_iso}"></i>`);
$dataAntInput.val($('.data-ant li[data-value="' + parsedData.ant + '"]').first().text());
if (parsedData.bw < 500) {
$dataBwInput.val($('.data-bw li[data-value2="' + parsedData.bw + '"]').first().text());
} else {
$dataBwInput.val($('.data-bw li[data-value="' + parsedData.bw + '"]').first().text());
}
if (typeof parsedData.agc !== 'undefined') $dataAgcInput.val($('.data-agc li[data-value="' + parsedData.agc + '"]').first().text());
if (parsedData.bw < 500) $dataBwInput.val($('.data-bw li[data-value2="' + parsedData.bw + '"]').first().text());
else $dataBwInput.val($('.data-bw li[data-value="' + parsedData.bw + '"]').first().text());
if (parsedData.txInfo.tx.length > 1) {
updateTextIfChanged($('#data-station-name'), parsedData.txInfo.tx.replace(/%/g, '%25'));
updateTextIfChanged($('#data-station-erp'), parsedData.txInfo.erp);
@@ -1028,17 +1028,12 @@ const updateDataElements = throttle(function(parsedData) {
updateHtmlIfChanged($('#alternative-txes'), altTxInfo);
updateTextIfChanged($('#data-station-distance'), txDistance);
$dataStationContainer.css('display', 'block');
} else {
$dataStationContainer.removeAttr('style');
}
if(parsedData.txInfo.tx.length > 1 && parsedData.txInfo.dist > 150 && parsedData.txInfo.dist < 4000) {
$('.log-fmlist').removeAttr('disabled').removeClass('btn-disabled cursor-disabled');
} else {
$('.log-fmlist').attr('disabled', 'true').addClass('btn-disabled cursor-disabled');
}
} else $dataStationContainer.removeAttr('style');
if(parsedData.txInfo.tx.length > 1 && parsedData.txInfo.dist > 150 && parsedData.txInfo.dist < 4000) $('.log-fmlist').removeAttr('disabled').removeClass('btn-disabled cursor-disabled');
else $('.log-fmlist').attr('disabled', 'true').addClass('btn-disabled cursor-disabled');
updateHtmlIfChanged($('#data-regular-pi'), parsedData.txInfo.reg === true ? parsedData.txInfo.pi : '&nbsp;');
if (updateCounter % 8 === 0) {
$dataTp.html(parsedData.tp === 0 ? "<span class='opacity-half'>TP</span>" : "TP");
$dataTa.html(parsedData.ta === 0 ? "<span class='opacity-half'>TA</span>" : "TA");
@@ -1050,7 +1045,7 @@ const updateDataElements = throttle(function(parsedData) {
)
);
}
if (updateCounter % 30 === 0) {
$dataPs.attr('aria-label', parsedData.ps);
$dataRt0.attr('aria-label', parsedData.rt0);
@@ -1063,36 +1058,35 @@ let isEventListenerAdded = false;
function updatePanels(parsedData) {
updateCounter = (updateCounter % 10000) + 1; // Count to 10000 then reset back to 1
signalData.push(parsedData.sig);
if (signalData.length > 8) {
signalData.shift(); // Remove the oldest element
}
const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0);
const averageSignal = sum / signalData.length;
const sortedAf = parsedData.af.sort(compareNumbers);
const scaledArray = sortedAf.map(element => element / 1000);
const listContainer = $('#af-list');
const scrollTop = listContainer.scrollTop();
let ul = listContainer.find('ul');
if (!ul.length) {
ul = $('<ul></ul>');
listContainer.append(ul);
}
if (updateCounter % 3 === 0) {
updateButtonState("data-eq", parsedData.eq);
updateButtonState("data-ims", parsedData.ims);
// Only update #af-list on every 3rd call
ul.html('');
const listItems = scaledArray.map(createListItem);
ul.append(listItems);
// Add the event listener only once
if (!isEventListenerAdded) {
ul.on('click', 'a', function () {
@@ -1101,10 +1095,10 @@ function updatePanels(parsedData) {
});
isEventListenerAdded = true;
}
listContainer.scrollTop(scrollTop);
}
updateDataElements(parsedData);
updateSignalUnits(parsedData, averageSignal);
$('.users-online').text(parsedData.users);
@@ -1116,11 +1110,11 @@ function createListItem(element) {
function updateButtonState(buttonId, value) {
var button = $("#" + buttonId);
if (button.length === 0) {
button = $("." + buttonId);
}
if (button.length > 0) {
if (value == 0) {
button.hasClass("btn-disabled") ? null : button.addClass("btn-disabled");
@@ -1152,7 +1146,7 @@ function toggleForcedStereo() {
function toggleLock(buttonSelector, activeMessage, inactiveMessage, activeLabel, inactiveLabel) {
let $lockButton = $(buttonSelector);
if ($lockButton.hasClass('active')) {
socket.send(inactiveMessage);
$lockButton.attr('aria-label', inactiveLabel);
@@ -1166,17 +1160,17 @@ function toggleLock(buttonSelector, activeMessage, inactiveMessage, activeLabel,
function showTunerDescription() {
let parentDiv = $("#tuner-name").parent();
if (!$("#dashboard-panel-description").is(":visible")) {
parentDiv.css("border-radius", "15px 15px 0 0");
}
$("#dashboard-panel-description").slideToggle(300, function() {
if (!$(this).is(":visible")) {
parentDiv.css("border-radius", "");
}
});
$("#tuner-name i").toggleClass("rotated");
if ($(window).width() < 768) {
@@ -1187,29 +1181,29 @@ function showTunerDescription() {
function initTooltips(target = null) {
// Define scope: all tooltips or specific one if target is provided
const tooltips = target ? $(target) : $('.tooltip');
// Unbind existing event handlers before rebinding to avoid duplication
tooltips.off('mouseenter mouseleave');
tooltips.hover(function () {
if ($(this).closest('.popup-content').length) {
return;
}
var tooltipText = $(this).data('tooltip');
var placement = $(this).data('tooltip-placement') || 'top'; // Default to 'top'
// Clear existing timeouts
$(this).data('timeout', setTimeout(() => {
$('.tooltip-wrapper').remove();
var tooltip = $(`
<div class="tooltip-wrapper">
<div class="tooltiptext">${tooltipText}</div>
</div>
`);
$('body').append(tooltip);
var tooltipEl = $('.tooltiptext');
var tooltipWidth = tooltipEl.outerWidth();
var tooltipHeight = tooltipEl.outerHeight();
@@ -1217,7 +1211,7 @@ function initTooltips(target = null) {
var targetOffset = targetEl.offset();
var targetWidth = targetEl.outerWidth();
var targetHeight = targetEl.outerHeight();
// Compute position
var posX, posY;
switch (placement) {
@@ -1239,7 +1233,7 @@ function initTooltips(target = null) {
posY = targetOffset.top - tooltipHeight - 10;
break;
}
// Apply positioning
tooltipEl.css({ top: posY, left: posX, opacity: 1 });
@@ -1247,32 +1241,32 @@ function initTooltips(target = null) {
if ((/Mobi|Android|iPhone|iPad|iPod|Opera Mini/i.test(navigator.userAgent)) && ('ontouchstart' in window || navigator.maxTouchPoints)) {
setTimeout(() => { $('.tooltiptext').remove(); }, 5000);
}
}, 300));
}, function () {
clearTimeout($(this).data('timeout'));
setTimeout(() => {
$('.tooltip-wrapper').fadeOut(300, function () {
$(this).remove();
$(this).remove();
});
}, 100);
}, 100);
});
$('.popup-content').off('mouseenter').on('mouseenter', function () {
clearTimeout($('.tooltip').data('timeout'));
$('.tooltip-wrapper').fadeOut(300, function () {
$(this).remove();
$(this).remove();
});
});
}
function fillPresets() {
let hasAnyPreset = false;
for (let i = 1; i <= 4; i++) {
let presetText = localStorage.getItem(`preset${i}`);
if (presetText != "null") {
hasAnyPreset = true;
$(`#preset${i}-text`).text(presetText);
@@ -1283,9 +1277,8 @@ function initTooltips(target = null) {
$(`#preset${i}`).hide();
}
}
if (!hasAnyPreset) {
$('#preset1').parent().hide();
}
}

View File

@@ -3,15 +3,15 @@ $(document).ready(function() {
var modalPanel = $(".modal-panel");
var openBtn = $(".settings");
var closeBtn = $(".closeModal, .closeModalButton");
initPopups();
openBtn.on("click", function() {
openModal(modalPanel);
});
closeBtn.on("click", closeModal);
function openModal(panel) {
modal.css("display", "block");
panel.css("display", "block");
@@ -20,7 +20,7 @@ $(document).ready(function() {
modal.css("opacity", 1);
}, 10);
}
function closeModal() {
modal.css("opacity", 0);
setTimeout(function() {
@@ -28,24 +28,20 @@ $(document).ready(function() {
$("body").removeClass("modal-open"); // Enable body scrolling
}, 300);
}
$(document).on("click", function(event) { // Close the modal when clicking outside of it
if ($(event.target).is(modal)) {
closeModal();
}
if ($(event.target).is(modal)) closeModal();
});
$(document).on("keydown", function(event) { // Close the modal when pressing ESC key
if (event.key === "Escape") {
closeModal();
}
if (event.key === "Escape") closeModal();
});
$(".tuner-mobile-settings").on("click", function () {
togglePopup("#popup-panel-mobile-settings");
});
$("#data-station-others").on("click", function () {
togglePopup("#popup-panel-transmitters");
});
@@ -54,13 +50,13 @@ $(document).ready(function() {
function initPopups() {
$(".popup-window").draggable({
handle: ".popup-header",
containment: "body"
containment: "body"
}).resizable({
minHeight: 330,
minWidth: 350,
containment: "body"
});
$(".popup-close").on("click", function () {
$(".popup-window").fadeOut(200);
});
@@ -69,9 +65,8 @@ function initPopups() {
function togglePopup(targetSelector) {
const $target = $(targetSelector);
if ($target.is(":visible")) {
$target.fadeOut(200);
} else {
if ($target.is(":visible")) $target.fadeOut(200);
else {
$(".popup-window").fadeOut(200);
$target.fadeIn(200);
}

View File

@@ -75,6 +75,27 @@ function getQueryParameter(name) {
return urlParams.get(name);
}
function updateFavicon(color) {
function rgbToHex(rgb) {
const result = rgb.match(/\d+/g);
return "#" + result.slice(0, 3).map(x =>(+x).toString(16).padStart(2, "0")).join("");
}
const hex = rgbToHex(color);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<circle cx="64" cy="64" r="54" fill="none" stroke="${hex}" stroke-width="20"/>
<circle cx="64" cy="64" r="22" fill="none" stroke="white" stroke-width="18"/>
</svg>`;
const base64 = btoa(svg);
$('#favicon').attr(
'href',
`data:image/svg+xml;base64,${base64}`
);
}
function setTheme(themeName) {
const themeColors = themes[themeName];
if (themeColors) {
@@ -94,6 +115,7 @@ function setTheme(themeName) {
$(':root').css('--color-text', themeColors[2]);
$(':root').css('--color-text-2', textColor2);
$('.wrapper-outer').css('background-color', backgroundColorWithOpacity);
updateFavicon(themeColors[1]);
}
}
@@ -167,9 +189,7 @@ function loadInitialSettings() {
if(signalParameter && !localStorage.getItem('signalUnit')) {
signalSelector.find('input').val(signalSelector.find('.option[data-value="' + signalParameter + '"]').text());
localStorage.setItem('signalUnit', signalParameter);
} else {
signalSelector.find('input').val(signalSelector.find('.option[data-value="' + savedUnit + '"]').text());
}
} else signalSelector.find('input').val(signalSelector.find('.option[data-value="' + savedUnit + '"]').text());
signalSelector.on('click', '.option', (event) => {
const selectedSignalUnit = $(event.target).data('value');

View File

@@ -24,11 +24,10 @@ function mapCreate() {
if (!(typeof map == "object")) {
map = L.map('map', {
center: [40, 0],
zoom: 3
zoom: 3,
worldCopyJump: true
});
} else {
map.setZoom(3).panTo([40, 0]);
}
} else map.setZoom(3).panTo([40, 0]);
L.tileLayer(tilesURL, {
attribution: mapAttrib,
@@ -55,9 +54,9 @@ function mapCreate() {
$('#identification-lon').val(ev.latlng.lng.toFixed(6));
if (typeof pin == "object") {
pin.setLatLng(ev.latlng);
pin.setLatLng(ev.latlng.wrap());
} else {
pin = L.marker(ev.latlng, { riseOnHover: true, draggable: true }).addTo(map);
pin = L.marker(ev.latlng.wrap(), { riseOnHover: true, draggable: true }).addTo(map);
pin.on('dragend', function(ev) {
$('#identification-lat').val(ev.target.getLatLng().lat.toFixed(6));
$('#identification-lon').val(ev.target.getLatLng().lng.toFixed(6));
@@ -116,11 +115,8 @@ function initBanlist() {
data: { ip: ipAddress, reason: reason },
success: function(response) {
// Refresh the page if the request was successful
if (response.success) {
location.reload();
} else {
console.error('Failed to add to banlist');
}
if (response.success) location.reload();
else console.error('Failed to add to banlist');
},
error: function() {
console.error('Error occurred during the request');
@@ -262,8 +258,8 @@ function checkTunnelServers() {
url: '/tunnelservers',
method: 'GET',
success: function(servers) {
const $options = $('#tunnel-server ul.options');
const $input = $('#tunnel-serverSelect');
const $options = $('#tunnel-regionselect ul.options');
const $input = $('#tunnel-region');
const selectedValue = $input.val(); // currently selected value (label or value?)
servers.forEach(server => {
@@ -274,9 +270,7 @@ function checkTunnelServers() {
// If this li is the currently selected one, update input text too
// Note: input.val() holds the label, so match by label is safer
if ($li.text() === selectedValue || server.value === selectedValue) {
$input.val(server.label);
}
if ($li.text() === selectedValue || server.value === selectedValue) $input.val(server.label);
}
});
},

View File

@@ -1,2 +1,2 @@
const versionDate = new Date('Nov 30, 2025 23:00:00');
const currentVersion = `v1.3.12 [${versionDate.getDate()}/${versionDate.getMonth() + 1}/${versionDate.getFullYear()}]`;
const versionDate = new Date('Feb 24, 2026 15:00:00');
const currentVersion = `v1.4.0a [${versionDate.getDate()}/${versionDate.getMonth() + 1}/${versionDate.getFullYear()}]`;

View File

@@ -27,8 +27,6 @@ function navigateStep(isNext) {
currentStep.hide();
targetStep.show();
updateProgressBar(targetStep);
} else if (isNext) {
submitConfig();
}
} else if (isNext) submitConfig();
updateWizardContent();
}

View File

@@ -8,7 +8,7 @@
<script src="js/libs/jquery.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" type="text/css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
@@ -16,7 +16,7 @@
<div class="wrapper-outer wrapper-full">
<div id="wrapper">
<div class="panel-100 no-bg">
<img class="top-25" src="favicon.png" height="64px">
<img class="top-25" src="favicon.svg" height="64px">
<p>You are currently not logged in as an administrator and therefore can't change the settings.</p>
<p>Please login below.</p>
</div>

View File

@@ -8,7 +8,7 @@
<script src="js/libs/jquery.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" type="text/css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
@@ -109,7 +109,7 @@
<a href="https://dnschecker.org/ip-location.php?ip=<%= user.ip.replace('::ffff:', '') %>" target="_blank">
<%= user.ip.replace('::ffff:', '') %>
</a>
</td>
</td>
<td><%= user.location %></td>
<td><%= user.time %></td>
<td><a href="./kick?ip=<%= user.ip %>">Kick</a></td>
@@ -121,10 +121,10 @@
</tr>
<% } %>
</tbody>
</table>
</table>
</div>
</div>
<div class="flex-container">
<div class="panel-100-real p-bottom-20">
<h3>Quick settings</h3>
@@ -132,7 +132,7 @@
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Unlocked Tuner', id: 'publicTuner'}) %>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Admin lock', id: 'lockToAdmin'}) %><br>
</div>
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Tune password', id: 'password-tunePass', password: true}) %>
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Admin password', id: 'password-adminPass', password: true}) %><br>
</div>
@@ -156,7 +156,7 @@
<div class="panel-full tab-content no-bg m-0" id="audio" role="tabpanel">
<h2>Audio settings</h2>
<div class="flex-container contains-dropdown">
<div class="panel-33 p-bottom-20">
<h3>Device</h3>
@@ -181,14 +181,14 @@
label: `${device.name}`
}))
]
}) %>
}) %>
</div>
<div class="panel-33 p-bottom-20">
<h3>Channels</h3>
<p>Audio channel count.<br>
<span class="text-gray">Choose between Mono / Stereo.</span>
</p>
<%- include('_components', { component: 'dropdown', id: 'audio-channels-dropdown', inputId: 'audio-audioChannels', label: 'Audio channels', cssClass: '', placeholder: 'Stereo',
<%- include('_components', { component: 'dropdown', id: 'audio-channels-dropdown', inputId: 'audio-audioChannels', label: 'Audio channels', cssClass: '', placeholder: 'Stereo',
options: [
{ value: '2', label: 'Stereo' },
{ value: '1', label: 'Mono' }
@@ -200,7 +200,7 @@
<p>The bitrate of the mp3 audio.<br>
<span class="text-gray">Minimum: 64 Kbps • Maximum: 320 Kbps</span>
</p>
<%- include('_components', { component: 'dropdown', id: 'audio-quality-dropdown', inputId: 'audio-audioBitrate', label: 'Audio quality', cssClass: '', placeholder: '128kbps (standard)',
<%- include('_components', { component: 'dropdown', id: 'audio-quality-dropdown', inputId: 'audio-audioBitrate', label: 'Audio quality', cssClass: '', placeholder: '128kbps (standard)',
options: [
{ value: '64k', label: '64kbps (lowest quality)' },
{ value: '96k', label: '96kbps (low quality)' },
@@ -215,8 +215,8 @@
<div class="flex-container">
<div class="panel-50 p-bottom-20">
<h3>Volume</h3>
<p>This option will boost the audio volume globally, recommended for the Headless TEF.</p>
<h3>Audio boost</h3>
<p>This option will boost the audio volume. Use if the output is too quiet.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Audio Boost', id: 'audio-audioBoost'}) %>
</div>
<div class="panel-50 p-bottom-20">
@@ -251,11 +251,11 @@
</div>
<div class="panel-50 p-bottom-20">
<h3>Design</h3>
<h4>Background image</h4>
<%- include('_components', {component: 'text', cssClass: 'br-15', placeholder: 'Direct image link', label: 'Image link', id: 'webserver-bgImage'}) %><br>
<h4>Background image</h4>
<%- include('_components', {component: 'text', cssClass: 'br-15', placeholder: 'Direct image link', label: 'Image link', id: 'webserver-bgImage'}) %><br>
<h4 class="top-25">Themes</h4>
<%- include('_components', { component: 'dropdown', id: 'server-theme-selector', inputId: 'webserver-defaultTheme', label: 'Default server theme', cssClass: '', placeholder: 'Default',
<%- include('_components', { component: 'dropdown', id: 'server-theme-selector', inputId: 'webserver-defaultTheme', label: 'Default server theme', cssClass: '', placeholder: 'Default',
options: [
{ value: 'theme1', label: 'Mint' },
{ value: 'theme2', label: 'Cappuccino' },
@@ -280,17 +280,17 @@
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Antenna 1', id: 'antennas-ant1-enabled'}) %>
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant A', label: 'Antenna 1 name', id: 'antennas-ant1-name'}) %><br>
</div>
<div class="flex-container flex-phone flex-column bottom-20" style="margin-left: 15px; margin-right: 15px;">
<%- include('_components', {component: 'checkbox', cssClass: 'top-25', label: 'Antenna 2', id: 'antennas-ant2-enabled'}) %>
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant B', label: 'Antenna 2 name', id: 'antennas-ant2-name'}) %><br>
</div>
<div class="flex-container flex-phone flex-column bottom-20" style="margin-left: 15px; margin-right: 15px;">
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Antenna 3', id: 'antennas-ant3-enabled'}) %>
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant C', label: 'Antenna 3 name', id: 'antennas-ant3-name'}) %><br>
</div>
<div class="flex-container flex-phone flex-column bottom-20" style="margin-left: 15px; margin-right: 15px;">
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Antenna 4', id: 'antennas-ant4-enabled'}) %>
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant D', label: 'Antenna 4 name', id: 'antennas-ant4-name'}) %><br>
@@ -337,7 +337,7 @@
<div class="panel-50 p-bottom-20" style="padding-left: 20px; padding-right: 20px; padding-bottom: 80px;">
<h3>Transmitter Search Algorithm</h3>
<p>Different modes may help with more accurate transmitter identification depending on your region.</p>
<%- include('_components', { component: 'dropdown', id: 'server-tx-id-algo', inputId: 'webserver-txIdAlgorithm', label: 'Transmitter ID Algorithm', cssClass: '', placeholder: 'Algorithm 1',
<%- include('_components', { component: 'dropdown', id: 'server-tx-id-algo', inputId: 'webserver-txIdAlgorithm', label: 'Transmitter ID Algorithm', cssClass: '', placeholder: 'Algorithm 1',
options: [
{ value: '0', label: 'Algorithm 1' },
{ value: '1', label: 'Algorithm 2' },
@@ -351,23 +351,22 @@
<div class="panel-full m-0 tab-content no-bg" id="tuner" role="tabpanel">
<h2>Tuner settings</h2>
<div class="flex-container contains-dropdown">
<div class="panel-33 p-bottom-20">
<div class="panel-100 p-bottom-20 contains-dropdown" style="z-index: 991;">
<h3>Device type</h3>
<%- include('_components', { component: 'dropdown', id: 'device-selector', inputId: 'device', label: 'Device', cssClass: '', placeholder: 'TEF668x / TEA685x',
options: [
{ value: 'tef', label: 'TEF668x / TEA685x' },
{ value: 'xdr', label: 'XDR (F1HD / S10HDiP)' },
{ value: 'sdr', label: 'SDR (RTL-SDR / AirSpy)' },
{ value: 'other', label: 'Other' }
]
}) %><br>
<div class="flex-center" style="max-width: 520px; margin: 10px auto 0;">
<%- include('_components', { component: 'dropdown', id: 'device-selector', inputId: 'device', label: 'Device', cssClass: '', placeholder: 'TEF668x / TEA685x',
options: tunerProfiles.map(profile => ({
value: profile.id,
label: profile.label
}))
}) %><br>
</div>
</div>
<div class="panel-33 p-bottom-20" style="padding-right: 20px; padding-left: 20px;">
<div class="flex-container contains-dropdown">
<div class="panel-100 p-bottom-20" style="padding-right: 20px; padding-left: 20px;">
<h3>Connection type</h3>
<p class="text-gray">If you want to choose the COM port directly, choose "Direct".<br>If you use xdrd or your receiver is connected via Wi-Fi, choose TCP/IP.</p>
<p class="text-gray">If you want to choose the serial port directly, choose "Direct".<br>If you use xdrd or your receiver is connected via Wi-Fi, choose TCP/IP.</p>
<div class="auto top-10">
<label class="toggleSwitch nolabel" onclick="">
<input id="xdrd-wirelessConnection" type="checkbox" tabindex="0" aria-label="Connection type"/>
@@ -375,29 +374,25 @@
<span>
<span class="left-span">Direct</span>
<span class="right-span">TCP/IP</span>
</span>
</span>
</label>
</div>
</div>
<div class="panel-33 p-bottom-20">
<h3>Device / Server</h3>
<div id="tuner-usb">
<p class="text-gray">Choose your desired <strong>COM port</strong><br>&nbsp;</p>
<p class="text-gray">Choose your desired <strong>serial port</strong><br>&nbsp;</p>
<%- include('_components', {
component: 'dropdown',
id: 'deviceList',
inputId: 'xdrd-comPort',
label: 'USB Device',
label: 'Serial port',
cssClass: '',
placeholder: 'Choose your USB device',
placeholder: 'Choose your serial port',
options: serialPorts.map(serialPort => ({
value: serialPort.path,
label: `${serialPort.path} - ${serialPort.friendlyName}`
}))
}) %>
}) %>
</div>
<div id="tuner-wireless">
<p class="text-gray">If you are connecting your tuner <strong>wirelessly</strong>, enter the tuner IP. <br> If you use <strong>xdrd</strong>, use 127.0.0.1 as your IP.</p>
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', label: 'xdrd IP address', id: 'xdrd-xdrdIp'}) %>
@@ -414,7 +409,7 @@
<input type="range" id="audio-startupVolume" min="0" max="1" step="0.01" value="1" aria-label="Startup Volume slider">
</div>
<h4 class="top-10 text-gray" id="volume-percentage-value"></h4>
<hr>
<h4 class="bottom-20">Default frequency</h4>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Default frequency for first client', id: 'enableDefaultFreq'}) %><br>
@@ -433,6 +428,13 @@
<p>Toggling this option will put the tuner to sleep when no clients are connected.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Auto-shutdown', id: 'autoShutdown'}) %><br>
</div>
<div class="flex-container">
<div class="panel-50 no-bg">
<h4>SI47XX AGC control</h4>
<p>Allow users to change SI47XX AGC mode from the main UI.</p>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Enable AGC control', id: 'si47xx-agcControl'}) %><br>
</div>
</div>
</div>
</div>
</div>
@@ -466,7 +468,7 @@
<div class="flex-container">
<div class="panel-50 p-bottom-20">
<h3>Basic info</h3>
<p>Set your tuner name and description here.<br>This info will be visible to anyone who tunes in. </p>
<div class="panel-full no-bg" style="padding-left: 20px; padding-right: 20px;">
<label for="identification-tunerName" style="width: 100%;max-width: 768px; margin:auto;">Webserver name:</label>
@@ -484,7 +486,7 @@
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Broadcast to map', id: 'identification-broadcastTuner'}) %><br>
<%- include('_components', {component: 'text', cssClass: 'br-15', placeholder: 'Your e-mail or Discord...', label: 'Owner contact', id: 'identification-contact'}) %>
<%- include('_components', {component: 'text', cssClass: 'br-15', label: 'Proxy address', id: 'identification-proxyIp'}) %>
<p>Check your tuner at <strong><a href="https://servers.fmdx.org" target="_blank" class="color-4">servers.fmdx.org</a></strong>.</p>
<p class="text-small text-gray">By activating the <strong>Broadcast to map</strong> option,<br>you agree to the <a href="https://fmdx.org/projects/webserver.php#rules" target="_blank">Terms of Service</a>.</p>
</div>
@@ -496,7 +498,7 @@
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Latitude', id: 'identification-lat'}) %>
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Longitude', id: 'identification-lon'}) %>
<div id="map"></div>
<br>
</div>
@@ -506,7 +508,7 @@
<h2>User management</h2>
<div class="panel-100">
<h3>Chat options</h3>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Chat', id: 'webserver-chatEnabled'}) %>
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Chat', id: 'webserver-chatEnabled'}) %>
</div>
<div class="panel-100 p-bottom-20">
@@ -571,33 +573,33 @@
<p>These settings will be applied after a server launch or restart.</p>
<div class="flex-container flex-center p-20">
<% if (device === 'tef') { %>
<%- include('_components', { component: 'dropdown', id: 'ceqStartup-dropdown', inputId: 'ceqStartup', label: 'cEQ', cssClass: '', placeholder: 'Disabled',
<%- include('_components', { component: 'dropdown', id: 'ceqStartup-dropdown', inputId: 'ceqStartup', label: 'cEQ', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'imsStartup-dropdown', inputId: 'imsStartup', label: 'iMS', cssClass: '', placeholder: 'Disabled',
<%- include('_components', { component: 'dropdown', id: 'imsStartup-dropdown', inputId: 'imsStartup', label: 'iMS', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<% } else if (device === 'xdr') { %>
<%- include('_components', { component: 'dropdown', id: 'rfStartup-dropdown', inputId: 'ceqStartup', label: 'RF+', cssClass: '', placeholder: 'Disabled',
<%- include('_components', { component: 'dropdown', id: 'rfStartup-dropdown', inputId: 'ceqStartup', label: 'RF+', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'ifStartup-dropdown', inputId: 'imsStartup', label: 'IF+', cssClass: '', placeholder: 'Disabled',
<%- include('_components', { component: 'dropdown', id: 'ifStartup-dropdown', inputId: 'imsStartup', label: 'IF+', cssClass: '', placeholder: 'Disabled',
options: [
{ value: '0', label: 'Disabled' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<% } %>
<%- include('_components', { component: 'dropdown', id: 'stereoStartup-dropdown', inputId: 'stereoStartup', label: 'Stereo Mode', cssClass: '', placeholder: 'Stereo (Default)',
<%- include('_components', { component: 'dropdown', id: 'stereoStartup-dropdown', inputId: 'stereoStartup', label: 'Stereo Mode', cssClass: '', placeholder: 'Stereo (Default)',
options: [
{ value: '0', label: 'Stereo (Default)' },
{ value: '1', label: 'Mono' },
@@ -605,7 +607,7 @@
}) %><br>
</div>
<div class="panel-100-real p-bottom-20 no-bg">
<%- include('_components', { component: 'dropdown', id: 'antennaStartup-dropdown', inputId: 'antennaStartup', label: 'Antenna', cssClass: '', placeholder: 'Antenna 0 (Default)',
<%- include('_components', { component: 'dropdown', id: 'antennaStartup-dropdown', inputId: 'antennaStartup', label: 'Antenna', cssClass: '', placeholder: 'Antenna 0 (Default)',
options: [
{ value: '0', label: 'Antenna 0 (Default)' },
{ value: '1', label: 'Antenna 1' },
@@ -621,21 +623,21 @@
<h3>Empty server defaults</h3>
<p>These settings will apply once the last user disconnects from the server, so the server can be ready for a new user with default settings.</p>
<div class="flex-container flex-center p-20">
<%- include('_components', { component: 'dropdown', id: 'bwAutoNoUsers-dropdown', inputId: 'bwAutoNoUsers', label: 'Auto BW', cssClass: '', placeholder: 'Unchanged',
<%- include('_components', { component: 'dropdown', id: 'bwAutoNoUsers-dropdown', inputId: 'bwAutoNoUsers', label: 'Auto BW', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Enabled' },
]
}) %><br>
<% if (device === 'tef') { %>
<%- include('_components', { component: 'dropdown', id: 'ceqNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'cEQ', cssClass: '', placeholder: 'Unchanged',
<%- include('_components', { component: 'dropdown', id: 'ceqNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'cEQ', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
{ value: '2', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'imsNoUsers-dropdown', inputId: 'imsNoUsers', label: 'iMS', cssClass: '', placeholder: 'Unchanged',
<%- include('_components', { component: 'dropdown', id: 'imsNoUsers-dropdown', inputId: 'imsNoUsers', label: 'iMS', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
@@ -643,14 +645,14 @@
]
}) %><br>
<% } else if (device === 'xdr') { %>
<%- include('_components', { component: 'dropdown', id: 'rfNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'RF+', cssClass: '', placeholder: 'Unchanged',
<%- include('_components', { component: 'dropdown', id: 'rfNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'RF+', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
{ value: '2', label: 'Enabled' },
]
}) %><br>
<%- include('_components', { component: 'dropdown', id: 'ifNoUsers-dropdown', inputId: 'imsNoUsers', label: 'IF+', cssClass: '', placeholder: 'Unchanged',
<%- include('_components', { component: 'dropdown', id: 'ifNoUsers-dropdown', inputId: 'imsNoUsers', label: 'IF+', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Disabled' },
@@ -658,7 +660,7 @@
]
}) %><br>
<% } %>
<%- include('_components', { component: 'dropdown', id: 'stereoNoUsers-dropdown', inputId: 'stereoNoUsers', label: 'Stereo Mode', cssClass: '', placeholder: 'Unchanged',
<%- include('_components', { component: 'dropdown', id: 'stereoNoUsers-dropdown', inputId: 'stereoNoUsers', label: 'Stereo Mode', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Stereo' },
@@ -668,7 +670,7 @@
</div>
<div class="panel-100-real p-bottom-20 no-bg">
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Delayed Antenna Change', id: 'antennaNoUsersDelay'}) %><br>
<%- include('_components', { component: 'dropdown', id: 'antennaNoUsers-dropdown', inputId: 'antennaNoUsers', label: 'Antenna', cssClass: '', placeholder: 'Unchanged',
<%- include('_components', { component: 'dropdown', id: 'antennaNoUsers-dropdown', inputId: 'antennaNoUsers', label: 'Antenna', cssClass: '', placeholder: 'Unchanged',
options: [
{ value: '0', label: 'Unchanged' },
{ value: '1', label: 'Antenna 0' },
@@ -699,14 +701,16 @@
<div class="panel-100 p-bottom-20">
<h3>Tunnel</h3>
<p>When you become an <a href="https://buymeacoffee.com/fmdx" target="_blank"><strong>FMDX.org supporter</strong></a>, you can host your webserver without the need of a public IP address & port forwarding.<br>
When you become a supporter, you can message the Founders on Discord for your login details.</p>
When you become a supporter, you can message the Founders on Discord for your login details.</p><br>
<p>You can also get an tunnel from kuba201 discord, one of the contributors of this version of the application.</p>
<h4>Main tunnel settings</h4>
<%- include('_components', {component: 'checkbox', cssClass: 'm-right-10', label: 'Enable tunnel', id: 'tunnel-enabled'}) %><br>
<%- include('_components', { component: 'dropdown', id: 'tunnel-server', inputId: 'tunnel-serverSelect', label: 'Official server region', cssClass: '', placeholder: 'Europe',
<%- include('_components', { component: 'dropdown', id: 'tunnel-regionSelect', inputId: 'tunnel-region', label: 'Official server region', cssClass: '', placeholder: 'Europe',
options: [
{ value: 'eu', label: 'Europe' },
{ value: 'us', label: 'Americas' },
{ value: 'sg', label: 'Asia & Oceania' },
{ value: 'pldx', label: 'Poland (k201)' },
]
}) %>
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Username', id: 'tunnel-username'}) %>

View File

@@ -8,7 +8,7 @@
<script src="js/libs/jquery.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" type="text/css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
@@ -47,15 +47,13 @@
<h3 class="settings-heading">Tuner type</h3>
<p class="m-0">Settings a proper device type ensures that the correct interface and settings will load.</p>
<div class="panel-100 no-bg flex-center">
<%- include('_components', { component: 'dropdown', id: 'device-selector', inputId: 'device', label: 'Device', cssClass: '', placeholder: 'TEF668x / TEA685x',
options: [
{ value: 'tef', label: 'TEF668x / TEA685x' },
{ value: 'xdr', label: 'XDR (F1HD / S10HDiP)' },
{ value: 'sdr', label: 'SDR (RTL-SDR / AirSpy)' },
{ value: 'other', label: 'Other' }
]
}) %><br>
<div class="panel-100 no-bg flex-center" style="max-width: 520px; margin: 10px auto 0;">
<%- include('_components', { component: 'dropdown', id: 'device-selector', inputId: 'device', label: 'Device', cssClass: '', placeholder: 'TEF668x / TEA685x',
options: tunerProfiles.map(profile => ({
value: profile.id,
label: profile.label
}))
}) %><br>
</div>
<div class="clearfix"></div>
<h3 class="settings-heading">Tuner connection</h3>
@@ -66,25 +64,25 @@
<span>
<span class="left-span">Direct</span>
<span class="right-span">TCP/IP</span>
</span>
</span>
</label>
</div>
<div id="tuner-usb" class="top-25">
<p>It's time to choose your USB device.</p>
<p>It's time to choose your serial port.</p>
<div class="panel-100 no-bg flex-center">
<%- include('_components', {
component: 'dropdown',
id: 'deviceList',
inputId: 'xdrd-comPort',
label: 'USB Device',
label: 'Serial port',
cssClass: '',
placeholder: 'Choose your USB device',
placeholder: 'Choose your serial port',
options: serialPorts.map(serialPort => ({
value: serialPort.path,
label: `${serialPort.path} - ${serialPort.friendlyName}`
}))
}) %>
}) %>
</div>
</div>
<div class="clearfix"></div>
@@ -105,7 +103,7 @@
<p class="m-0">In this section, we will set up the audio.<br>
Choose the audio port your tuner is connected to and desired audio settings here.</p>
<p class="text-gray">Recommended defaults have already been set for the audio quality, you can keep them as-is.</p>
<div class="panel-100 no-bg p-bottom-20 flex-container flex-center">
<%- include('_components', {
component: 'dropdown',
@@ -124,16 +122,16 @@
label: `${device.name}`
}))
]
}) %>
<%- include('_components', { component: 'dropdown', id: 'audio-channels-dropdown', inputId: 'audio-audioChannels', label: 'Audio channels', cssClass: '', placeholder: 'Stereo',
}) %>
<%- include('_components', { component: 'dropdown', id: 'audio-channels-dropdown', inputId: 'audio-audioChannels', label: 'Audio channels', cssClass: '', placeholder: 'Stereo',
options: [
{ value: '2', label: 'Stereo' },
{ value: '1', label: 'Mono' }
]
}) %>
<%- include('_components', { component: 'dropdown', id: 'audio-quality-dropdown', inputId: 'audio-audioBitrate', label: 'Audio quality', cssClass: '', placeholder: '128kbps (standard)',
<%- include('_components', { component: 'dropdown', id: 'audio-quality-dropdown', inputId: 'audio-audioBitrate', label: 'Audio quality', cssClass: '', placeholder: '128kbps (standard)',
options: [
{ value: '64k', label: '64kbps (lowest quality)' },
{ value: '96k', label: '96kbps (low quality)' },
@@ -170,7 +168,7 @@
<br>
<label for="identification-tunerDesc" style="width: 100%;max-width: 768px; margin: auto;">Webserver description:</label>
<textarea id="identification-tunerDesc" name="webserver-desc" class="br-15" placeholder="Fill the server description here. You can put useful info here such as your antenna setup. You can use simple markdown." maxlength="255"></textarea>
<h3 class="settings-heading">Location</h3>
<p>Location info is useful for automatic identification of stations using RDS.</p>
<div class="panel-100 no-bg flex-container flex-center">