-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.js
428 lines (403 loc) · 14.2 KB
/
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
import {default as RCON} from "rcon-srcds";
import {XMLParser} from "fast-xml-parser";
import {decode} from "html-entities";
import * as toml from "toml";
const parser = new XMLParser({
ignoreAttributes: false, alwaysCreateTextNode: true, processEntities: false, //FIX: while changing songs VLC may fail to respond with stream info
//this causes FXP to assume that category should be an object instead of array
isArray: (_tagName, jPath, _isLeafNode, _isAttribute,) => {
return jPath === "root.information.category" || jPath === "root.information.category.info";
},
},);
const VLCCommandQueue = [];
const TF2Password = generateRandomString();
const VLCPassword = generateRandomString();
const VLCPlayWord = generateRandomString();
const VLCPauseWord = generateRandomString();
const VLCInfoWord = generateRandomString();
const VLCNextWord = generateRandomString();
const defaultConfig = {
TF2: {
TF2Path: "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Team Fortress 2\\tf_win64.exe",
ConLogPath: "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Team Fortress 2\\tf\\console.log",
TF2Port: 27015,
MaxAuthRetries: 10,
LinuxLineEndings: false,
TF2LaunchArguments: "-novid\n-nojoy\n-nosteamcontroller\n-nohltv\n-particles 1\n-precachefontchars\n"
}, VLC: {
VLCPath: "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe",
PlaylistPath: "E:\\Hella Gud Christmas Playlist\\christmas.m3u",
VLCPort: 9090
}, Other: {RefreshMilliseconds: 400}
}
let config;
let fileSize = 0;
let firstRead = false;
let chatString = "";
let timestampString = " at 0:00/0:00";
let authRetry;
let authRetryCounter = 0;
let TF2Args;
let VLCArgs;
let VLCWasPaused = false;
let isAlltalkEnabled = false;
function loadConfig() {
let failedRead = false;
let TOMLObj;
try {
const decoder = new TextDecoder("utf-8");
const TOMLText = decoder.decode(Deno.readFileSync("config.toml"));
TOMLObj = toml.parse(TOMLText);
} catch (e) {
failedRead = true;
if (e instanceof Error && e.name === "NotFound") {
console.warn("Warning: Could not find config.toml file, default config will be used.")
} else {
console.error(e)
}
}
if (!failedRead) {
config = recursiveMerge(defaultConfig, TOMLObj);
config.Other.RefreshMilliseconds = Math.min(config.Other.RefreshMilliseconds, 1000)
} else {
config = defaultConfig;
}
const TF2BuiltInArgs = `-steam
-condebug
-conclearlog
-usercon
+ip 127.0.0.1
-port ${config.TF2.TF2Port}
+sv_rcon_whitelist_address 127.0.0.1
+rcon_password ${TF2Password}
+net_start`.split("\n");
TF2Args = [...TF2BuiltInArgs, ...config.TF2.TF2LaunchArguments.split("\n")];
VLCArgs = `${config.VLC.PlaylistPath}
--extraintf=http
--http-host=127.0.0.1
--http-port=${config.VLC.VLCPort}
--http-password=${VLCPassword}`.split("\n");
}
async function readStreamAsText(stream, format) {
const decoder = new TextDecoder(format);
let text = "";
let wholeBufferLength = 0;
for await (const chunk of stream) {
text += decoder.decode(chunk, {stream: true});
wholeBufferLength += chunk.length;
}
return {text: text, size: wholeBufferLength};
}
async function readNewLines() {
const conLogFileHandle = await Deno.open(config.TF2.ConLogPath, {read: true});
await conLogFileHandle.seek(fileSize, Deno.SeekMode.Start);
const streamData = await readStreamAsText(conLogFileHandle.readable, "utf-8");
fileSize += streamData.size;
const lines = streamData.text.split(config.TF2.LinuxLineEndings ? "\n" : "\r\n");
getCVARAsBool("sv_alltalk").then((isAlltalk) => {
if (isAlltalk !== null) {
isAlltalkEnabled = isAlltalk;
}
}).catch(() => {
isAlltalkEnabled = false;
});
if (streamData.size === 0) {
return;
}
if (!firstRead) {
firstRead = true;
return;
}
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith("(Demo Support) Start recording demos",)) {
/*TF2 has a bug where if you disconnect while using the voice chat the client will think it's still speaking
fixing this using +voicerecord;-voicerecord when joining*/
sendTF2Command("+voicerecord;-voicerecord");
}
/*We use includes instead of startsWith because of a bug in the source engine where it doesn't guarantee that
consecutive echo commands should be on their own line*/
if (lines[i].startsWith("(Demo Support) End recording") || lines[i].includes(`${VLCPauseWord} `) || (lines[i].startsWith("You have switched to team") && !isAlltalkEnabled)) {
queueVLCCommand("pl_forcepause");
sendTF2Command("-voicerecord");
VLCWasPaused = true;
}
if (lines[i].includes(`${VLCNextWord} `)) {
queueVLCCommand("pl_next");
sendTF2Command("+voicerecord");
VLCWasPaused = false;
}
if (lines[i].includes(`${VLCPlayWord} `)) {
queueVLCCommand("pl_forceresume");
sendTF2Command("+voicerecord");
VLCWasPaused = false;
}
if (lines[i].includes(`${VLCInfoWord} `)) {
announceSong(true);
}
}
return;
}
function beforeUnload() {
try {
VLC.kill();
} catch {
//ignore
}
try {
teamFortress.kill();
} catch {
//ignore
}
}
/**
* Generates a random cryptographic string
*/
function generateRandomString() {
const possibleChars = "abcdefghijklmnopqrstuvwxyz";
const randNumbers = crypto.getRandomValues(new Uint32Array(32));
let randomString = "";
for (let i = 0; i < randNumbers.length; i++) {
randomString += possibleChars[randNumbers[i] % possibleChars.length];
}
return randomString;
}
function tryAuth() {
authRetry = setTimeout(function () {
console.log("Attempting RCON connection to TF2");
if (authRetryCounter++ === config.TF2.MaxAuthRetries) {
throw "Could not establish RCON connection to TF2";
}
RCONClient.authenticate(TF2Password)
.then(RCONSuccess)
.catch(function (e) {
if (e.message.includes("ECONNREFUSED")) {
tryAuth();
} else if (e.message.includes("Unable to authenticate")) {
console.error("UNABLE TO AUTHENTICATE, TRY CLOSING TF2");
Deno.exit(1);
} else {
console.error(e);
Deno.exit(1);
}
});
}, 5000);
}
async function RCONSuccess() {
console.log("Authenticated with TF2");
sendTF2Command(`alias VLCPLAY "echo ${VLCPlayWord}"
alias VLCPAUSE "echo ${VLCPauseWord}"
alias VLCINFO "echo ${VLCInfoWord}"
alias VLCNEXT "echo ${VLCNextWord}"
voice_loopback 1
ds_enable 2
con_timestamp 0
voice_buffer_ms 200`);
clearInterval(authRetry);
queueVLCCommand("pl_forcepause");
timer();
}
async function checkMetaData() {
let nextCommand = ""
if (VLCCommandQueue.length > 0) {
nextCommand = `?command=${VLCCommandQueue[0]}`
}
let response;
try {
response = await fetch(`http://:${VLCPassword}@127.0.0.1:${config.VLC.VLCPort}/requests/status.xml${nextCommand}`);
} catch (e) {
// Occurs when it's been too long since the last fetch... ???
if (e.message.includes("An existing connection was forcibly closed by the remote host.")) {
return false;
} else {
throw e;
}
} finally {
VLCCommandQueue.shift();
}
if (!response.ok || response.body === null) {
return false;
}
const jObj = parser.parse(await response.text());
const TSTime = convertSecondsToTimestamp(jObj.root.time["#text"], 1);
const TSLength = convertSecondsToTimestamp(jObj.root.length["#text"], 1);
timestampString = `${TSTime}/${TSLength}`;
const paused = (jObj.root.state["#text"] === "paused");
if (paused !== VLCWasPaused) {
if (paused) {
sendTF2Command("-voicerecord");
} else {
sendTF2Command("+voicerecord");
}
VLCWasPaused = paused;
}
if (paused) {
return true;
}
let metaInfo = [];
let artistName = "";
let titleName = "";
let fileName = "";
for (const cat of jObj.root.information.category) {
if (cat["@_name"] === "meta") {
if ('info' in cat) {
metaInfo = cat.info;
} else {
//VLC will sometimes return a category with meta as its name but no info in it when songs are switching.
return true;
}
}
}
for (const info of metaInfo) {
switch (info["@_name"]) {
case "artist":
artistName = info["#text"];
break;
case "title":
titleName = info["#text"];
break;
case "filename":
fileName = info["#text"];
break;
}
}
let tempString;
const incompleteMeta = artistName === "" || titleName === "";
if (incompleteMeta) {
tempString = `${fileName}`;
} else {
tempString = `${artistName} - ${titleName}`;
}
// This has to be decoded twice
tempString = decode(decode(tempString, {level: "xml"}), {level: "html5"});
if (chatString === tempString) {
return true;
}
if (incompleteMeta) {
console.warn(`Invalid metadata in: ${fileName}, title: ${titleName}, artist: ${artistName}.
fix this using Mp3tag or similar.`,);
}
chatString = tempString;
announceSong(false);
return true;
}
function announceSong(timestamp) {
const messageMode = isAlltalkEnabled ? "say" : "say_team"
if (!timestamp) {
RCONClient.execute(messageMode + formatChatMessage(`♪ Now Playing: ${chatString} ♪`));
} else {
RCONClient.execute(messageMode + formatChatMessage(`♪ Currently Playing: ${chatString}. Currently at ${timestampString} ♪`,));
}
}
function queueVLCCommand(command) {
VLCCommandQueue.push(command);
if (VLCCommandQueue.length > 5) {
VLCCommandQueue.shift();
}
}
function sendTF2Command(command) {
RCONClient.execute(command);
}
/**
* Formats a message to be a TF2 team chat command.
* Cuts text off with a "..." if it's longer than 127 bytes encoded in UTF-8
* @param message original text to be converted to a command
*/
function formatChatMessage(message) {
message.replaceAll('"', "''");
const encoder = new TextEncoder();
const fitAbleBytes = encoder.encodeInto(message, new Uint8Array(127));
if (fitAbleBytes.read < message.length) {
const fitTextInto = new Uint8Array(124);
const utf8Cut = encoder.encodeInto(message, fitTextInto);
message = message.slice(0, utf8Cut.read) + "...";
}
return `"${message}"`;
}
// I don't know what I'll do with seconds over 59 hours long
/**
* Returns a string of the seconds formatted as a timestamp (5:54)
* @param seconds Seconds to use for the calculation.
* @param minSeparators Minimum amount of ":" in the result.
*/
function convertSecondsToTimestamp(seconds, minSeparators,) {
if (seconds < 0) {
seconds = 0;
}
if (minSeparators === undefined || minSeparators < 0) {
minSeparators = 0;
}
minSeparators = Math.floor(minSeparators);
const calculatedSeparators = Math.floor(Math.max(Math.log(seconds), 0) / Math.log(60),);
const separators = Math.max(calculatedSeparators, minSeparators,);
const outputTimes = [];
for (let i = separators; i >= 0; i--) {
const addedSegment = (Math.floor(seconds % 60)).toString();
if (i === 0) {
outputTimes.unshift(addedSegment.padStart(1, "0"));
} else {
outputTimes.unshift(addedSegment.padStart(2, "0"));
}
seconds /= 60;
}
return outputTimes.join(":");
}
function recursiveMerge(base, overlay) {
let newObject
if (Array.isArray(base) && !Array.isArray(overlay)) {
newObject = Object.assign({}, base);
} else {
newObject = structuredClone(base);
}
for (const key of Object.keys(overlay)) {
if (overlay[key].constructor !== base[key].constructor) continue;
if (typeof overlay[key] === "object" && typeof base[key] === "object") {
newObject[key] = recursiveMerge(base[key], overlay[key]);
} else {
newObject[key] = overlay[key];
}
}
return newObject;
}
function timer() {
setTimeout(async () => {
await readNewLines();
await checkMetaData();
timer();
}, config.Other.RefreshMilliseconds);
}
/*
Valve provides no clean way to get cvars, furthermore, parts of the output like "def" "min" "max" are in *randomized*
positions whenever multithreading is enabled, multithreading can't be disabled instantly so we cannot work around it.
therefore, the output of this function is just a guesstimate, as writing a proper tokenizer and parser for the output
would probably take me weeks.
If you're reading this Valve, please fire whoever decided that console output should be threaded.
This function will not properly return the cvar value of any cvar with " in its value because of the above.
*/
async function getCVAR(cvar) {
const response = await RCONClient.execute(`help "${cvar}"`);
const beginRegex = new RegExp(`(?<="${cvar}" = ").*?(?=")`)
const match = response.match(beginRegex)
if (match === null) return null;
return match[0]
}
async function getCVARAsBool(cvar) {
const CVARint = parseInt(await getCVAR(cvar));
if (isNaN(CVARint)) return null;
return (CVARint !== 0)
}
loadConfig()
console.debug(`VLC HTTP Password: ${VLCPassword}`);
const teamFortress = new Deno.Command(config.TF2.TF2Path, {args: TF2Args}).spawn();
teamFortress.output().then(() => {
Deno.exit(0);
});
const VLC = new Deno.Command(config.VLC.VLCPath, {args: VLCArgs}).spawn();
VLC.output().then(() => {
Deno.exit(0);
});
Deno.addSignalListener("SIGINT", beforeUnload);
globalThis.addEventListener("unload", beforeUnload);
const RCONClient = new RCON.default({
port: config.TF2.TF2Port, encoding: "utf8",
},);
tryAuth();