← Back to changelog
What changed
Stations2 · v2.0.0 → v2.0.1 (current)
Diff
2 added · 2 removed · 1889 → 1889 total lines
1
1
/*▄▄▄▄ ███▄ ▄███▓ ▄████ ▄▄▄██▀▀▀▓█████▄▄▄█████▓
2
2
▓█████▄ ▓██▒▀█▀ ██▒ ██▒ ▀█▒ ▒██ ▓█ ▀▓ ██▒ ▓▒
3
3
▒██▒ ▄██▓██ ▓██░▒██░▄▄▄░ ░██ ▒███ ▒ ▓██░ ▒░
4
4
▒██░█▀ ▒██ ▒██ ░▓█ ██▓▓██▄██▓ ▒▓█ ▄░ ▓██▓ ░
5
5
░▓█ ▀█▓▒██▒ ░██▒░▒▓███▀▒ ▓███▒ ░▒████▒ ▒██▒ ░
6
6
░▒▓███▀▒░ ▒░ ░ ░ ░▒ ▒ ▒▓▒▒░ ░░ ▒░ ░ ▒ ░░
7
7
▒░▒ ░ ░ ░ ░ ░ ░ ▒ ░▒░ ░ ░ ░ ░
8
8
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
9
9
░ ░ ░ ░ ░ ░ ░
10
10
Chat Commands
11
11
12
12
Set MP3 URL (Stations2.MP3 Privileges Required)
13
13
/mp3
14
14
Sets the MP3 link on boomboxes currently being held or looked at.
15
15
16
16
Create Cassettes from YouTube Link (Stations2.YT Privileges Required)
17
17
/yt
18
18
Download youtube video and convert it to cassettes
19
19
20
20
Console Commands (RCON/Plugins/Admin)
21
21
22
22
Change Boombox URL by NetID
23
23
stationsset <NetID> <URL>
24
24
25
25
Give Cassettes to Player
26
26
stationstapes <URL> <NameOrIDorIP>
27
27
28
28
List All Cassettes
29
29
stationslist
30
30
31
31
Remove Cassette
32
32
stationsremove <Index>
33
33
34
34
Notes
35
35
This plugin functions by distributing your YouTube audio across multiple cassette tapes,
36
36
with each tape containing up to 30 seconds of audio due to game limitations.
37
37
Be cautious not to lose any tapes in the set, as missing parts will render the audio incomplete and unusable.
38
38
39
39
Requires Node/js npm to install deno to solve youtube security since cookies now only covers about 60% of videos
40
40
*/
41
41
using Newtonsoft.Json;
42
42
using System.Collections;
43
43
using System;
44
44
using System.Collections.Generic;
45
45
using System.Diagnostics;
46
46
using System.IO;
47
47
using System.Linq;
48
48
using System.Net;
49
49
using System.Text.RegularExpressions;
50
50
using UnityEngine;
51
51
using Oxide.Core.Plugins;
52
52
using System.Threading.Tasks;
53
53
using HarmonyLib;
54
54
using Oxide.Core;
55
55
using Oxide.Game.Rust.Cui;
56
56
namespace Oxide.Plugins
57
57
{
58
-
[Info("Stations2", "bmgjet", "2.0.0")]
58
+
[Info("Stations2", "bmgjet", "2.0.1")]
59
59
class Stations2 : RustPlugin
60
60
{
61
61
//Perms
62
62
private readonly string MP3Perm = "Stations2.MP3";
63
63
private readonly string YTPerm = "Stations2.YT";
64
64
private readonly string NoLimitPerm = "Stations2.NoLimit";
65
65
66
66
//Self Reference
67
67
public static Stations2 codebase;
68
68
69
69
//Vars
70
70
private string installPath = Path.Combine(Interface.Oxide.DataDirectory, "Stations");
71
71
private Dictionary<ulong, float> cooldowns = new Dictionary<ulong, float>();
72
72
private List<Coroutine> YT_DLP = new List<Coroutine>();
73
73
private Dictionary<BaseEntity, Coroutine> TapeThreads = new Dictionary<BaseEntity, Coroutine>();
74
74
private List<TapeQueue> _queue = new List<TapeQueue>();
75
75
private readonly object _queueLock = new object();
76
76
private Coroutine _queuechecker = null;
77
77
private string VersionString;
78
78
private bool WinOS;
79
79
private bool Linux;
80
80
private string CUICheckSum;
81
81
82
82
//ConversionJobs
83
83
public class TapeQueue
84
84
{
85
85
public BasePlayer Player;
86
86
public string Url;
87
87
public int Status = 0;
88
88
}
89
89
90
90
//Tapes
91
91
public class CassetteParts
92
92
{
93
93
public ulong ID = 0;
94
94
public string title = "";
95
95
public ulong ownerid = 0;
96
96
public List<ulong> netids = new List<ulong>();
97
97
public List<uint> crc = new List<uint>();
98
98
public List<int> lengths = new List<int>();
99
99
}
100
100
101
101
#region Harmony Hooks
102
102
//Casset HooksDeployedRecorder
103
103
[AutoPatch]
104
104
[HarmonyPatch(typeof(DeployedRecorder), "OnCassetteInserted", typeof(Cassette))] internal class DeployedRecorder_OnCassetteInserted {[HarmonyPrefix] static bool Prefix(Cassette c, DeployedRecorder __instance) { try { return codebase.DeployedRecorder(c, __instance); } catch { } return true; } }
105
105
//Casset Hook Deployed Bookox and held boombox
106
106
[AutoPatch]
107
107
[HarmonyPatch(typeof(BoomBox), "OnCassetteInserted", typeof(Cassette))] internal class BoomBox_OnCassetteInserted {[HarmonyPrefix] static bool Prefix(Cassette c, BoomBox __instance) { try { return codebase.BoomBox(c, __instance); } catch { } return true; } }
108
108
//Oxide has no Play hook on deployed recorder so add one
109
109
[AutoPatch]
110
110
[HarmonyPatch(typeof(DeployedRecorder), "ServerTogglePlay", typeof(bool))] internal class DeployedRecorder_ServerTogglePlay {[HarmonyPrefix] static bool Prefix(DeployedRecorder __instance) { try { return codebase.OnRecorderToggle(__instance); } catch { } return true; } }
111
111
#endregion
112
112
113
113
#region Configuration
114
114
private Configuration config;
115
115
private class Configuration
116
116
{
117
117
[JsonProperty("Show Debug Info")]
118
118
public bool debug = false;
119
119
120
120
[JsonProperty("Cassette Limit Per Player")]
121
121
public int CassetteLimit = 3;
122
122
123
123
[JsonProperty("Max Youtube Video Length (Seconds)")]
124
124
public int MaxVideoLength = 600;
125
125
126
126
[JsonProperty("Delete Cassettes With Missing Parts On Startup")]
127
127
public bool AutoClean = true;
128
128
129
129
[JsonProperty("Use Remote Cookies File")]
130
130
public bool PublicCookies = false;
131
131
132
132
[JsonProperty("Remote Cookies File URL")]
133
133
public string PublicCookiesURL = "";
134
134
135
135
[JsonProperty("Local Cookies Path")]
136
136
public string CookiesPath = "";
137
137
138
138
[JsonProperty("Use Extra Args")]
139
139
public bool UseExtraArgs = false;
140
140
141
141
[JsonProperty("Extra YT-DLP Args")]
142
142
public string ExtraArgs = " --cookies-from-browser firefox ";
143
143
144
144
[JsonProperty("YouTube Downloader And FFMPEG Stall Timeout (Sec)")]
145
145
public int TimeOut = 30;
146
146
147
147
[JsonProperty("Queue Update Message Rate (Sec)")]
148
148
public int MessageTimeout = 10;
149
149
150
150
[JsonProperty("Cooldown Between Command Useage (Seconds)")]
151
151
public int Cooldown = 20;
152
152
153
153
[JsonProperty("Cooldown Between Cassette Removal (Seconds)")]
154
154
public int TapeCooldown = 5;
155
155
156
156
[JsonProperty("Current Installed Version Of YT-DLP (Dont Edit)")]
157
157
public string lastVersionYouTubeDL;
158
158
159
159
[JsonProperty("Max Cassette Length (Dont Edit)")]
160
160
public int cassetlength = 30;
161
161
162
162
[JsonProperty("Cassette Data (Dont Edit)")]
163
163
public List<CassetteParts> CustomCassettes = new List<CassetteParts>();
164
164
165
165
public string ToJson() => JsonConvert.SerializeObject(this);
166
166
167
167
public Dictionary<string, object> ToDictionary() => JsonConvert.DeserializeObject<Dictionary<string, object>>(ToJson());
168
168
}
169
169
170
170
protected override void LoadDefaultConfig() { config = new Configuration(); }
171
171
protected override void LoadConfig()
172
172
{
173
173
base.LoadConfig();
174
174
try
175
175
{
176
176
config = Config.ReadObject<Configuration>();
177
177
if (config == null) { throw new JsonException(); }
178
178
179
179
if (!config.ToDictionary().Keys.SequenceEqual(Config.ToDictionary(x => x.Key, x => x.Value).Keys))
180
180
{
181
181
PrintWarning("Configuration appears to be outdated; updating and saving");
182
182
SaveConfig();
183
183
}
184
184
}
185
185
catch
186
186
{
187
187
PrintWarning($"Configuration file {Name}.json is invalid; using defaults");
188
188
LoadDefaultConfig();
189
189
}
190
190
}
191
191
protected override void SaveConfig()
192
192
{
193
193
PrintWarning($"Configuration changes saved to {Name}.json");
194
194
Config.WriteObject(config, true);
195
195
}
196
196
#endregion Configuration
197
197
198
198
#region Oxide Hooks
199
199
private void OnNewSave()
200
200
{
201
201
//Wipe custom cassettes on map wipes
202
202
Puts("Reset Custom Cassette Data");
203
203
config.CustomCassettes.Clear();
204
204
SaveConfig();
205
205
}
206
206
207
207
private void OnServerInitialized()
208
208
{
209
209
//Setup
210
210
codebase = this;
211
211
permission.RegisterPermission(MP3Perm, this);
212
212
permission.RegisterPermission(YTPerm, this);
213
213
permission.RegisterPermission(NoLimitPerm, this);
214
214
VersionString = "Stations2 " + Version + " - " + SystemInfo.operatingSystem + " by bmgjet";
215
215
//Determine OS first - everything below depends on it
216
216
WinOS = SystemInfo.operatingSystem.StartsWith("Windows");
217
217
Linux = SystemInfo.operatingSystem.StartsWith("Linux");
218
218
if (!WinOS && !Linux) //Unknown OS
219
219
{
220
220
Puts("Stations2 Not Compatible with " + SystemInfo.operatingSystem);
221
221
}
222
222
if (!Directory.Exists(installPath))
223
223
{
224
224
Puts("Creating Folder");
225
225
Directory.CreateDirectory(installPath);
226
226
if (Linux)
227
227
{
228
228
Puts("Setting Folder Permissions");
229
229
SetPermissions(installPath, "777");
230
230
}
231
231
}
232
232
Task.Run(() =>
233
233
{
234
234
//Create CUI token
235
235
CUICheckSum = RandomString(8);
236
236
if (config.PublicCookies)
237
237
{
238
238
if (!string.IsNullOrEmpty(config.PublicCookiesURL) && config.PublicCookiesURL.StartsWith("http"))
239
239
{
240
240
try { using (WebClient wc = new WebClient()) { wc.DownloadFile(new Uri(config.PublicCookiesURL), Path.Combine(codebase.installPath, "cookies.txt")); } }
241
241
catch (Exception ex) { Puts("Failed to download remote cookies file: " + ex.Message); }
242
242
}
243
243
}
244
244
if (WinOS)
245
245
{
246
246
//Windows Dependencies
247
247
YouTubeDL.binName = "yt-dlp.exe";
248
248
YouTubeDL.downloadURL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe";
249
249
FFmpeg.binName = "ffmpeg.exe";
250
250
FFmpeg.downloadURL = "https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/ffmpeg.exe";
251
251
FFmpeg.Files = new Dictionary<string, string>()
252
252
{
253
253
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/swscale-6.dll", Path.Combine(installPath, "swscale-6.dll")},
254
254
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/swresample-4.dll", Path.Combine(installPath, "swresample-4.dll")},
255
255
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/postproc-56.dll", Path.Combine(installPath, "postproc-56.dll")},
256
256
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/avutil-57.dll", Path.Combine(installPath, "avutil-57.dll")},
257
257
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/avformat-59.dll", Path.Combine(installPath, "avformat-59.dll")},
258
258
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/avfilter-8.dll", Path.Combine(installPath, "avfilter-8.dll")},
259
259
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/avdevice-59.dll", Path.Combine(installPath, "avdevice-59.dll")},
260
260
{"https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/avcodec-59.dll", Path.Combine(installPath, "avcodec-59.dll")},
261
261
};
262
262
}
263
263
else if (Linux)
264
264
{
265
265
//Linux Dependencies
266
266
YouTubeDL.binName = "yt-dlp";
267
267
YouTubeDL.downloadURL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp";
268
268
FFmpeg.binName = "ffmpeg";
269
269
FFmpeg.downloadURL = "https://raw.githubusercontent.com/bmgjet/Stations2/refs/heads/main/ffmpeg";
270
270
FFmpeg.Files = new Dictionary<string, string>();
271
271
}
272
272
//Check dependencies
273
273
if (!YouTubeDL.isInstalled())
274
274
{
275
275
Puts("YT-DLP missing. Downloading....");
276
276
YouTubeDL.checkDownload();
277
277
}
278
278
else { YouTubeDL.checkForUpdates(); }
279
279
if (!FFmpeg.isInstalled())
280
280
{
281
281
Puts("FFMPEG missing. Downloading....");
282
282
FFmpeg.downloadAndInstall();
283
283
}
284
284
//YouTube now requires solving a JS signature/n challenge (yt-dlp's "EJS" system) to get
285
285
//real audio formats without it you only get thumbnail/image-only results. Deno is the
286
286
//JS runtime yt-dlp uses for this, and installs the same way cross-platform via npm.
287
287
if (!Deno.isInstalled())
288
288
{
289
289
Puts("Deno (JS runtime for YouTube challenge solving) missing. Installing via npm....");
290
290
Deno.downloadAndInstall();
291
291
}
292
292
});
293
293
Puts("Loaded: " + VersionString);
294
294
CleanUpMissingTapes();
295
295
}
296
296
297
297
//Oxide Hook For Pressing Play On Deployed And Held BoomBoxes
298
298
private object OnBoomboxToggle(BoomBox boomBox)
299
299
{
300
300
if (!boomBox.HasFlag(BaseEntity.Flags.On))
301
301
{
302
302
ItemContainer slot = (boomBox.baseEntity as DeployableBoomBox)?.inventory;
303
303
if (slot == null) { slot = (boomBox.baseEntity as HeldBoomBox)?.GetItem()?.contents; }
304
304
if (slot != null && slot?.itemList?.Count > 0)
305
305
{
306
306
Item cassette = slot.itemList[0];
307
307
if (cassette != null)
308
308
{
309
309
foreach (var c in config.CustomCassettes)
310
310
{
311
311
foreach (var id in c.netids)
312
312
{
313
313
if (id == cassette.instanceData.subEntity.Value)
314
314
{
315
315
BoomBox(BaseNetworkable.serverEntities.Find(cassette.instanceData.subEntity) as Cassette, boomBox);
316
316
return true;
317
317
}
318
318
}
319
319
}
320
320
}
321
321
}
322
322
}
323
323
else
324
324
{
325
325
if (TapeThreads.ContainsKey(boomBox.baseEntity))
326
326
{
327
327
ServerMgr.Instance.StopCoroutine(TapeThreads[boomBox.baseEntity]);
328
328
TapeThreads.Remove(boomBox.baseEntity);
329
329
}
330
330
}
331
331
return null;
332
332
}
333
333
334
334
//Added hook via Harmony
335
335
private bool OnRecorderToggle(BaseEntity recorder)
336
336
{
337
337
if (!recorder.HasFlag(BaseEntity.Flags.On))
338
338
{
339
339
ItemContainer slot = (recorder as DeployedRecorder)?.inventory;
340
340
if (slot != null && slot?.itemList?.Count > 0)
341
341
{
342
342
Item cassette = slot.itemList[0];
343
343
if (cassette != null)
344
344
{
345
345
foreach (var c in config.CustomCassettes)
346
346
{
347
347
foreach (var id in c.netids)
348
348
{
349
349
if (id == cassette.instanceData.subEntity.Value)
350
350
{
351
351
DeployedRecorder(BaseNetworkable.serverEntities.Find(cassette.instanceData.subEntity) as Cassette, recorder as DeployedRecorder);
352
352
return false;
353
353
}
354
354
}
355
355
}
356
356
}
357
357
}
358
358
}
359
359
else
360
360
{
361
361
if (TapeThreads.ContainsKey(recorder))
362
362
{
363
363
ServerMgr.Instance.StopCoroutine(TapeThreads[recorder]);
364
364
TapeThreads.Remove(recorder);
365
365
}
366
366
}
367
367
return true;
368
368
}
369
369
370
370
private void Unload()
371
371
{
372
372
//Stop Threads
373
373
foreach (Coroutine co in YT_DLP) { if (co != null) { ServerMgr.Instance.StopCoroutine(co); } }
374
374
foreach (KeyValuePair<BaseEntity, Coroutine> tape in TapeThreads) { if (tape.Value != null) { ServerMgr.Instance.StopCoroutine(tape.Value); } }
375
375
//Remove CUI
376
376
foreach (BasePlayer player in BasePlayer.activePlayerList) { CuiHelper.DestroyUi(player, "S2"); }
377
377
//Remove Temp Files
378
378
try
379
379
{
380
380
//Clean up any temp files
381
381
string tempfiles;
382
382
if (config.PublicCookies)
383
383
{
384
384
tempfiles = Path.Combine(installPath, "cookies.txt");
385
385
if (File.Exists(tempfiles)) { File.Delete(tempfiles); }
386
386
}
387
387
tempfiles = Path.Combine(installPath, "dl");
388
388
if (Directory.Exists(tempfiles))
389
389
{
390
390
System.IO.DirectoryInfo di = new DirectoryInfo(tempfiles);
391
391
foreach (FileInfo file in di.GetFiles()) { file.Delete(); }
392
392
}
393
393
}
394
394
catch { }
395
395
codebase = null;
396
396
}
397
397
#endregion
398
398
399
399
#region Chat/Console Commands
400
400
401
401
[ConsoleCommand("stationsset")] //API call to directly set youtube/mp3
402
402
private void ConsoleCommandSet(ConsoleSystem.Arg arg)
403
403
{
404
404
if ((arg.IsAdmin || arg.IsRcon) && arg.Args?.Length >= 2) //Check there are 3 or more args
405
405
{
406
406
try
407
407
{
408
408
if (arg.Args.Length > 1)
409
409
{
410
410
ulong netid = 0;
411
411
if (ulong.TryParse(arg.Args[0].ToString(), out netid))
412
412
{
413
413
//Find by passed netid
414
414
BaseNetworkable bn = BaseNetworkable.serverEntities.Find(new NetworkableId(netid));
415
415
if (bn != null) { ManuallySetRadio(bn as BaseEntity, arg.Args[1].ToString()); return; }
416
416
}
417
417
}
418
418
}
419
419
catch { }
420
420
string err = "Invalid Args!" + Environment.NewLine + "stationsset NetID URL";
421
421
if (arg.Player() != null) { arg.Player().ConsoleMessage(err); }
422
422
else { Puts(err); }
423
423
}
424
424
}
425
425
426
426
[ConsoleCommand("stationstapes")]
427
427
private void ConsoleCommandTapes(ConsoleSystem.Arg arg) //API call to directly give player youtube tapes
428
428
{
429
429
if ((arg.IsAdmin || arg.IsRcon) && arg.Args?.Length >= 2) //Check there are 2 or more args
430
430
{
431
431
try
432
432
{
433
433
//Find player
434
434
BasePlayer targetplayer = BasePlayer.FindAwakeOrSleeping(arg.Args[1].ToString());
435
435
if (targetplayer != null)
436
436
{
437
437
438
438
try { YouTubeTapes(targetplayer, arg.Args[0].ToString()); }
439
439
catch (Exception ex) { Puts(ex.ToString()); }
440
440
return;
441
441
}
442
442
}
443
443
catch { }
444
444
string err = "Invalid Args!" + Environment.NewLine + "stationstapes URL NameOrIDorIP";
445
445
if (arg.Player() != null) { arg.Player().ConsoleMessage(err); }
446
446
else { Puts(err); }
447
447
}
448
448
}
449
449
450
450
[ConsoleCommand("stationslist")]
451
451
private void ConsoleCommandList(ConsoleSystem.Arg arg) //API to get list of tapes
452
452
{
453
453
if (arg.IsAdmin)
454
454
{
455
455
var sb = new System.Text.StringBuilder("Cassette List:").AppendLine();
456
456
for (int i = config.CustomCassettes.Count - 1; i >= 0; i--)
457
457
{
458
458
try { sb.Append("Index: ").Append(config.CustomCassettes[i].ID).Append(" OwnerID: ").Append(config.CustomCassettes[i].ownerid).Append(" Title: ").Append(config.CustomCassettes[i].title).Append(" Tapes: ").Append(config.CustomCassettes[i].netids.Count).AppendLine(); } catch { }
459
459
}
460
460
string msg = sb.ToString();
461
461
if (arg.Player() != null) { arg.Player().ConsoleMessage(msg); }
462
462
else { Puts(msg); }
463
463
}
464
464
}
465
465
466
466
[ConsoleCommand("stationsremove")]
467
467
private void ConsoleCommandRemove(ConsoleSystem.Arg arg) //API to remove tape
468
468
{
469
469
if (arg.IsAdmin)
470
470
{
471
471
if (arg.Args?.Length == 1)
472
472
{
473
473
ServerMgr.Instance.StartCoroutine(RemoveCassettes(arg.Player(), ulong.Parse(arg.Args[0].ToString())));
474
474
return;
475
475
}
476
476
string err = "Invalid Args!" + Environment.NewLine + "stationsremove Index";
477
477
if (arg.Player() != null) { arg.Player().ConsoleMessage(err); }
478
478
else { Puts(err); }
479
479
}
480
480
}
481
481
482
482
483
483
[ChatCommand("mp3")]
484
484
private void PlayMP3ChatCmd(BasePlayer player, string command, string[] args)
485
485
{
486
486
if (!OnCoolDown(player)) //Check Not On Cooldown
487
487
{
488
488
if (permission.UserHasPermission(player.UserIDString, MP3Perm)) //Check Has Permission
489
489
{
490
490
BaseEntity baseEntity = FindBoomBox(player); //Try get entity
491
491
if (baseEntity != null)
492
492
{
493
493
CuiHelper.AddUi(player, RustUI(player, baseEntity.net.ID.Value, "", CUICheckSum)); //Create CUI
494
494
return;
495
495
}
496
496
player.ChatMessage("Couldn't Find BoomBox (Look at or hold one)"); //Error Message
497
497
}
498
498
}
499
499
}
500
500
501
501
[ChatCommand("yt")]
502
502
private void PlayYTChatCmd(BasePlayer player, string command, string[] args)
503
503
{
504
504
if (!OnCoolDown(player)) //Check Not On Cooldown
505
505
{
506
506
if (permission.UserHasPermission(player.UserIDString, YTPerm)) //Check Has Permission
507
507
{
508
508
CuiHelper.AddUi(player, RustUI(player, 0, "_", CUICheckSum)); //Create CUI
509
509
}
510
510
}
511
511
}
512
512
#endregion
513
513
514
514
#region CUI
515
515
516
516
[ConsoleCommand("SGUI")]
517
517
private void GUICMD(ConsoleSystem.Arg arg)
518
518
{
519
519
//Process CUI Button Presses
520
520
BasePlayer player = arg?.Player();
521
521
//null checks
522
522
if (player == null || arg?.Args?.Length < 3) { return; }
523
523
//Unique Key, Help against console injection
524
524
if (arg.Args[3] != CUICheckSum) { Puts("Invalid CUI Key: " + player.ToString()); CuiHelper.DestroyUi(player, "S2"); return; }
525
525
//Setup Args
526
526
BaseNetworkable info = null;
527
527
string url = (arg.Args.Count() == 5) ? arg.Args[4].ToString() : arg.Args[2].ToString();
528
528
ulong netid = 0;
529
529
if (ulong.TryParse(arg.Args[1].ToString(), out netid)) { info = BaseNetworkable.serverEntities.Find(new NetworkableId(netid)); } //Gets Boombox/Recorder from CUI Command
530
530
//Filter Commands
531
531
switch (arg.Args[0].ToString())
532
532
{
533
533
case "U": //Update
534
534
CuiHelper.AddUi(player, RustUI(player, (info != null) ? info.net.ID.Value : 0, url, CUICheckSum));
535
535
break;
536
536
case "S": //Set Stations
537
537
if (info != null)
538
538
{
539
539
if (info is DeployableBoomBox) { ChangeStationDeployed((info as DeployableBoomBox), url, player); }
540
540
else if (info is HeldBoomBox) { ChangeStationPortable((info as HeldBoomBox), url, player); }
541
541
CuiHelper.DestroyUi(player, "S2");
542
542
}
543
543
break;
544
544
case "D": //Download Cassets
545
545
YouTubeTapes(player, url);
546
546
CuiHelper.DestroyUi(player, "S2");
547
547
break;
548
548
case "L": //List Owned
549
549
CuiHelper.AddUi(player, CassetteListUI(player, CUICheckSum));
550
550
break;
551
551
case "R": //Remove Cassetts
552
552
if (OnCoolDown(player, true)) { return; }
553
553
if (cooldowns.ContainsKey(player.userID)) { cooldowns[player.userID] = Time.time; }
554
554
else { cooldowns.Add(player.userID, Time.time); }
555
555
ServerMgr.Instance.StartCoroutine(RemoveCassettes(player, netid, true, url));
556
556
break;
557
557
}
558
558
}
559
559
560
560
private CuiElementContainer RustUI(BasePlayer player, ulong netid, string url, string checksum)
561
561
{
562
562
//Create CUI
563
563
//Validate URL
564
564
bool youtube = false;
565
565
bool validurl = false;
566
566
if (string.IsNullOrEmpty(url)) { url = "_"; }
567
567
else
568
568
{
569
569
url.Replace(" ", "");
570
570
youtube = url.Contains("watch?v=");
571
571
validurl = isValidURL(url);
572
572
}
573
573
//Check Owned Tapes
574
574
int Owned = NumberOfCassettes(player.userID);
575
575
//Set up defaults settings
576
576
bool tapes = true;
577
577
int ItemID = 476066818;
578
578
if (netid != 0) //Provided NetID Run in MP3 Set Mode
579
579
{
580
580
BaseNetworkable bn = BaseNetworkable.serverEntities.Find(new NetworkableId(netid));
581
581
if (bn != null)
582
582
{
583
583
tapes = false;
584
584
var be = bn as BaseEntity;
585
585
if (be != null)
586
586
{
587
587
//Adjust Icon
588
588
switch (be.prefabID)
589
589
{
590
590
case 244503553: //Boombox
591
591
ItemID = -1113501606;
592
592
break;
593
593
case 617635188: //Portable Boombox
594
594
ItemID = 576509618;
595
595
break;
596
596
case 760079751: //Deployed CassetteRecorder
597
597
case 705457609: //CassetteRecorder
598
598
ItemID = -1530414568;
599
599
break;
600
600
}
601
601
}
602
602
if (youtube) { validurl = false; } //Trying to pass youtube link to mp3
603
603
}
604
604
}
605
605
else { if (!youtube) { validurl = false; } }//Missing YouTube Link
606
606
//Create Arg String
607
607
string info = netid + " " + url + " " + checksum;
608
608
//Create CUI
609
609
var container = new CuiElementContainer();
610
610
//Invisible Overlay Whole Screen
611
611
container.Add(new CuiElement
612
612
{
613
613
Name = "S2",
614
614
Parent = "Overlay",
615
615
DestroyUi = "S2",
616
616
Components =
617
617
{
618
618
new CuiNeedsCursorComponent(),
619
619
new CuiImageComponent{ Color = "1 1 1 0" },
620
620
new CuiRectTransformComponent{ AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = "0 0", OffsetMax = "0 0" }
621
621
}
622
622
});
623
623
//Create Panel
624
624
container.Add(new CuiElement
625
625
{
626
626
Name = "SGUI",
627
627
Parent = "S2",
628
628
DestroyUi = "SGUI",
629
629
Components =
630
630
{
631
631
new CuiNeedsCursorComponent(),
632
632
new CuiImageComponent{ Color = ".18 .18 .18 .95" },
633
633
new CuiRectTransformComponent{ AnchorMin = ".88 .82", AnchorMax = "1 1" }
634
634
}
635
635
});
636
636
//Create Text Label
637
637
container.Add(new CuiElement
638
638
{
639
639
Name = "SLabel",
640
640
Parent = "SGUI",
641
641
Components = {
642
642
new CuiTextComponent { Text = "URL:", FontSize = 10, Align = TextAnchor.MiddleLeft, Color = "1 1 1 1" },
643
643
new CuiOutlineComponent { Color = "0 0 0 .5", Distance = "1 -1" },
644
644
new CuiRectTransformComponent { AnchorMin = ".5 .5", AnchorMax = ".5 .5", OffsetMin = "-65 -38", OffsetMax = "-20 -18" }
645
645
}
646
646
});
647
647
//Create Close Button
648
648
container.Add(new CuiButton
649
649
{
650
650
Button = { Color = "1 1 1 1", Close = "S2" },
651
651
Text = { Text = "X", FontSize = 7, Align = TextAnchor.MiddleCenter, Color = "1 0 0 1" },
652
652
RectTransform = { AnchorMin = ".5 .5", AnchorMax = ".5 .5", OffsetMin = "55 50", OffsetMax = "65 60" }
653
653
}, "SGUI", "CBtn");
654
654
//Limit Check
655
655
string Downloadtxt = "Download YouTube";
656
656
if (!permission.UserHasPermission(player.UserIDString, NoLimitPerm))
657
657
{
658
658
if (tapes && Owned >= config.CassetteLimit)
659
659
{
660
660
validurl = false;
661
661
Downloadtxt = "At Limit [" + Owned + "/" + config.CassetteLimit + "]";
662
662
}
663
663
}
664
664
//Download Button
665
665
container.Add(new CuiButton
666
666
{
667
667
Button = { Color = (validurl ? "1 1 1 1" : ".5 .5 .5 1"), Command = !validurl ? "" : (tapes ? ("SGUI D " + info) : ("SGUI S " + info)) },
668
668
Text = { Text = tapes ? Downloadtxt : "Set MP3 Url", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "0 0 0 1" },
669
669
RectTransform = { AnchorMin = ".5 .5", AnchorMax = ".5 .5", OffsetMin = "-65 -60", OffsetMax = "65 -41" }
670
670
}, "SGUI", "SBtn");
671
671
672
672
//List Button
673
673
if (tapes)
674
674
{
675
675
container.Add(new CuiButton
676
676
{
677
677
Button = { Color = "1 1 1 1", Command = ("SGUI L " + info) },
678
678
Text = { Text = "List", FontSize = 7, Align = TextAnchor.MiddleCenter, Color = "0 0 0 1" },
679
679
RectTransform = { AnchorMin = ".5 .5", AnchorMax = ".5 .5", OffsetMin = "-65 50", OffsetMax = "-45 60" }
680
680
}, "SGUI", "MBtn");
681
681
}
682
682
//Create Icon
683
683
container.Add(new CuiElement
684
684
{
685
685
Name = "SIcon",
686
686
Parent = "SGUI",
687
687
Components = {
688
688
new CuiImageComponent { Color = "1 1 1 1", ItemId = ItemID },
689
689
new CuiOutlineComponent { Color = "0 0 0 .5", Distance = "1 -1" },
690
690
new CuiRectTransformComponent { AnchorMin = ".5 .5", AnchorMax = ".5 .5", OffsetMin = "-40 -20", OffsetMax = "40 60" }
691
691
}
692
692
});
693
693
//Create Whitebox
694
694
container.Add(new CuiElement
695
695
{
696
696
Name = "SBox",
697
697
Parent = "SGUI",
698
698
Components = {
699
699
new CuiImageComponent { Color = "1 1 1 .98", },
700
700
new CuiRectTransformComponent { AnchorMin = ".5 .5", AnchorMax = ".5 .5", OffsetMin = "-40 -34", OffsetMax = "65 -20" }
701
701
}
702
702
});
703
703
//Create Text Input
704
704
container.Add(new CuiElement
705
705
{
706
706
Name = "SIn",
707
707
Parent = "SBox",
708
708
Components = {
709
709
new CuiInputFieldComponent { Color = "0 0 0 1", FontSize = 10, Align = TextAnchor.UpperLeft, ReadOnly = false, IsPassword = false, Text = url , Command = "SGUI U " + info },
710
710
new CuiRectTransformComponent { AnchorMin = ".01 .01", AnchorMax = ".99 .99" }
711
711
}
712
712
});
713
713
return container;
714
714
}
715
715
716
716
private List<CuiElement> CassetteListUI(BasePlayer player, string checksum)
717
717
{
718
718
//Create New CUI
719
719
CuiElementContainer container = new CuiElementContainer();
720
720
List<CassetteParts> Clean = Facepunch.Pool.Get<List<CassetteParts>>(); //Get List of Custom Cassettes from config
721
721
for (int i = config.CustomCassettes.Count - 1; i >= 0; i--)
722
722
{
723
723
if (config.CustomCassettes[i].ownerid == player.userID) { Clean.Add(config.CustomCassettes[i]); }
724
724
}
725
725
//Setup Position based off Size
726
726
int loops = Clean.Count;
727
727
int offset = (loops - (int)(loops)) - 100;
728
728
//Create Panel
729
729
container.Add(new CuiPanel
730
730
{
731
731
CursorEnabled = true,
732
732
Image = { Color = ".18 .18 .18 .95" },
733
733
RectTransform = { AnchorMin = ".75 0", AnchorMax = ".88 1" }
734
734
}, "S2", "MTUI", "MTUI");
735
735
//Create Scroll view
736
736
container.Add(new CuiElement
737
737
{
738
738
Name = "SB",
739
739
Parent = "MTUI",
740
740
Components = {
741
741
new CuiScrollViewComponent {
742
742
MovementType = UnityEngine.UI.ScrollRect.MovementType.Elastic,
743
743
Vertical = true,
744
744
Inertia = true,
745
745
Horizontal = false,
746
746
Elasticity = 0.25f,
747
747
DecelerationRate = 0.3f,
748
748
ScrollSensitivity = 24f,
749
749
//Position based on calculated offsets for size
750
750
ContentTransform = new CuiRectTransform { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 " + ((150+(100 * loops)) * -1), OffsetMax = "0 250" },
751
751
VerticalScrollbar = new CuiScrollbar() { Size = 18f, AutoHide = true },
752
752
},
753
753
new CuiRawImageComponent
754
754
{
755
755
Sprite = "assets/content/effects/crossbreed/fx gradient skewed.png",
756
756
Color = ".05 .05 .05 .5"
757
757
}
758
758
}
759
759
});
760
760
//Title Bar
761
761
container.Add(new CuiElement
762
762
{
763
763
Name = "Title",
764
764
Parent = "MTUI",
765
765
Components = {
766
766
new CuiRawImageComponent
767
767
{
768
768
Sprite = "assets/content/effects/crossbreed/fx gradient skewed.png",
769
769
Color = ".25 .25 .25 0.6",},
770
770
new CuiRectTransformComponent { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 -28",OffsetMax = "-18 0"},
771
771
}
772
772
});
773
773
//Title Text
774
774
container.Add(new CuiElement
775
775
{
776
776
Name = "TT",
777
777
Parent = "MTUI",
778
778
Components = {
779
779
new CuiTextComponent { Text = "List Of Cassettes", FontSize = 12, Align = TextAnchor.MiddleCenter },
780
780
new CuiOutlineComponent { Color = "0 0 0 1", Distance = "1 1" },
781
781
new CuiRectTransformComponent { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 -28",OffsetMax = "-18 0" },
782
782
}
783
783
});
784
784
//Create X button
785
785
container.Add(new CuiButton
786
786
{
787
787
Button = { Color = ".5 .2 .2 1", Close = "MTUI" },
788
788
Text = { Text = "X", Font = "droidsansmono.ttf", FontSize = 11, Align = TextAnchor.MiddleCenter },
789
789
RectTransform = { AnchorMin = ".92 .965", AnchorMax = ".98 1" }
790
790
}, "MTUI", "CloseBttn");
791
791
//Create Elements inside Scroll view
792
792
foreach (var data in Clean)
793
793
{
794
794
//Create bar and adjusted position
795
795
offset -= 100;
796
796
string name = offset.ToString();
797
797
container.Add(new CuiElement
798
798
{
799
799
Name = name,
800
800
Parent = "SB",
801
801
Components = {
802
802
new CuiRawImageComponent{
803
803
Sprite = "assets/content/effects/crossbreed/fx gradient skewed.png",
804
804
Color = ".2 .2 .2 .4",},
805
805
new CuiRectTransformComponent { AnchorMin = ".05 .97", AnchorMax = ".95 .97", OffsetMin = "0 " + (offset - 99).ToString(), OffsetMax = "0 " + name},
806
806
}
807
807
});
808
808
//Create Icon
809
809
container.Add(new CuiElement
810
810
{
811
811
Name = "2SIcon" + name,
812
812
Parent = name,
813
813
Components = {
814
814
new CuiImageComponent { Color = "1 1 1 1", ItemId = 476066818 },
815
815
new CuiOutlineComponent { Color = "0 0 0 .5", Distance = "1 -1" },
816
816
new CuiRectTransformComponent { AnchorMin = ".1 0", AnchorMax = ".9 1" },
817
817
}
818
818
});
819
819
//Create Button On Icon
820
820
container.Add(new CuiButton
821
821
{
822
822
Button = { Color = "1 1 1 1", Command = ("SGUI R " + data.ID + " _ " + CUICheckSum) },
823
823
Text = { Text = "Remove Cassette", FontSize = 10, Align = TextAnchor.MiddleCenter, Color = "0 0 0 1" },
824
824
RectTransform = { AnchorMin = "0 0", AnchorMax = "1 .15", }
825
825
}, "2SIcon" + name, "_" + name);
826
826
//Create Info Text On Icon
827
827
container.Add(new CuiElement
828
828
{
829
829
Name = "i_" + name,
830
830
Parent = "2SIcon" + name,
831
831
Components = {
832
832
new CuiTextComponent { Text = data.title, FontSize = 8, Align = TextAnchor.MiddleLeft, Color = "1 1 1 1" },
833
833
new CuiOutlineComponent { Color = "0 0 0 .5", Distance = "1 -1" },
834
834
new CuiRectTransformComponent { AnchorMin = "0 .8", AnchorMax = "1 1" }
835
835
}
836
836
});
837
837
}
838
838
Facepunch.Pool.FreeUnmanaged(ref Clean);
839
839
//Store and Show
840
840
return container;
841
841
}
842
842
#endregion
843
843
844
844
#region Methods
845
845
//Precompiled, fully anchored URL check. Must start->end match a plausible http(s) URL with no
846
846
//whitespace/quote/backtick characters, so it can't smuggle extra command-line arguments.
847
847
private static readonly Regex ValidUrlRegex = new Regex(
848
848
@"^https?://[^\s""'`|;&<>]+\.[a-zA-Z]{2,}[^\s""'`|;&<>]*$",
849
849
RegexOptions.IgnoreCase | RegexOptions.Compiled);
850
850
private bool isValidURL(string URL) { return !string.IsNullOrWhiteSpace(URL) && ValidUrlRegex.IsMatch(URL); }
851
851
852
852
//Extra guard used immediately before a URL is handed to yt-dlp/ffmpeg as a command-line
853
853
//argument. A URL that starts with '-' can be parsed as a flag (e.g. yt-dlp's --exec) instead
854
854
//of a URL, which would let a player run arbitrary commands on the host. Reject that and any
855
855
//other shell/argument-breaking characters even though isValidURL already excludes them.
856
856
private bool IsSafeProcessArgument(string value)
857
857
{
858
858
if (string.IsNullOrWhiteSpace(value) || value.StartsWith("-")) { return false; }
859
859
foreach (char c in value) { if (char.IsWhiteSpace(c) || c == '"' || c == '\'' || c == '`' || c == '|' || c == ';' || c == '&' || c == '<' || c == '>') { return false; } }
860
860
return true;
861
861
}
862
862
863
863
//Create random string used to prevent CUI hacking. This token gates a console command that
864
864
//executes server-side actions from a client button press, so it needs to be unguessable -
865
865
//use a cryptographic RNG rather than System.Random.
866
866
public string RandomString(int length)
867
867
{
868
868
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
869
869
var bytes = new byte[length];
870
870
using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) { rng.GetBytes(bytes); }
871
871
var result = new char[length];
872
872
for (int i = 0; i < length; i++) { result[i] = chars[bytes[i] % chars.Length]; }
873
873
return new string(result);
874
874
}
875
875
876
876
//Check if its a youtube url and strip useless info off.
877
877
private void YouTubeTapes(BasePlayer player, string url)
878
878
{
879
879
if (url.Contains("&list=")) { url = url.Split(new string[] { "&list=" }, StringSplitOptions.None)[0]; }
880
880
881
881
//Reject anything that isn't a clean http(s) URL before it ever reaches a child process -
882
882
//this is what actually stops argument injection, the CUI button check is only cosmetic.
883
883
if (!isValidURL(url) || !IsSafeProcessArgument(url))
884
884
{
885
885
if (player != null) { player.ChatMessage("<color=red>Invalid URL.</color>"); }
886
886
return;
887
887
}
888
888
889
889
TapeQueue tapeQueue = new TapeQueue();
890
890
tapeQueue.Player = player;
891
891
tapeQueue.Url = url;
892
892
lock (_queueLock) { _queue.Add(tapeQueue); }
893
893
if (_queuechecker == null) { _queuechecker = ServerMgr.Instance.StartCoroutine(QueueChecker()); }
894
894
return;
895
895
}
896
896
897
897
//Break up length into parts
898
898
private int[] DistributeIntoGroups(float sum, int maxlength)
899
899
{
900
900
int[] groups = new int[(int)Math.Ceiling((sum / maxlength))];
901
901
for (int i = 0; i < groups.Length; i++) { groups[i] = i * maxlength; }
902
902
return groups;
903
903
}
904
904
905
905
//Get length of audio ogg
906
906
private float GetOggLength(byte[] t)
907
907
{
908
908
long num1 = -1L;
909
909
for (int i = t.Length - 1 - 8 - 2 - 4; i >= 0; i--) { if (t[i] == 79 && t[i + 1] == 103 && t[i + 2] == 103 && t[i + 3] == 83) { num1 = BitConverter.ToInt64(new byte[] { t[i + 6], t[i + 7], t[i + 8], t[i + 9], t[i + 10], t[i + 11], t[i + 12], t[i + 13] }, 0); break; } }
910
910
int num2 = -1;
911
911
for (int j = 0; j < t.Length - 8 - 2 - 4; j++) { if (t[j] == 118 && t[j + 1] == 111 && t[j + 2] == 114 && t[j + 3] == 98 && t[j + 4] == 105 && t[j + 5] == 115) { num2 = BitConverter.ToInt32(new byte[] { t[j + 11], t[j + 12], t[j + 13], t[j + 14] }, 0); break; } }
912
912
//Header not found or malformed - don't return a bogus/negative length
913
913
if (num1 <= 0 || num2 <= 0) { return 0; }
914
914
return num1 / num2;
915
915
}
916
916
917
917
//Get the number of cassettes a player has already created.
918
918
private int NumberOfCassettes(ulong UserID)
919
919
{
920
920
int num = 0;
921
921
//Check cassettes in config
922
922
foreach (var c in config.CustomCassettes)
923
923
{
924
924
if (c.ownerid == UserID) { num++; }
925
925
}
926
926
//Check if player has them queued up
927
927
lock (_queueLock) { foreach (var c in _queue) { if (c?.Player?.userID == UserID) { num++; } } }
928
928
return num;
929
929
}
930
930
931
931
//Check if player on cool down
932
932
private bool OnCoolDown(BasePlayer player, bool remove = false)
933
933
{
934
934
if (cooldowns.ContainsKey(player.userID))
935
935
{
936
936
if (cooldowns[player.userID] + ((remove) ? config.TapeCooldown : config.Cooldown) > Time.time)
937
937
{
938
938
player.ChatMessage("<color=red>Command on cooldown. (" + ((cooldowns[player.userID] + config.Cooldown) - Time.time).ToString() + ")</color>");
939
939
return true;
940
940
}
941
941
}
942
942
return false;
943
943
}
944
944
945
945
//Run console/terminal commands
946
946
public Process runCommand(string bin, string opts)
947
947
{
948
948
if (config.debug) { Puts($"{bin} args: {opts}"); }
949
949
Process process = new Process();
950
950
if (!string.IsNullOrEmpty(bin))
951
951
{
952
952
try
953
953
{
954
954
process.StartInfo = new ProcessStartInfo
955
955
{
956
956
WindowStyle = ProcessWindowStyle.Hidden,
957
957
FileName = bin,
958
958
Arguments = opts,
959
959
UseShellExecute = false,
960
960
RedirectStandardError = true,
961
961
RedirectStandardInput = true,
962
962
RedirectStandardOutput = true,
963
963
CreateNoWindow = true
964
964
};
965
965
process.EnableRaisingEvents = false;
966
966
}
967
967
catch (Exception value) { Puts(value.ToString()); }
968
968
}
969
969
return process;
970
970
}
971
971
972
972
//Adjust folder permission for Linux OS to allow temp files to write
973
973
public void SetPermissions(string path, string permissions)
974
974
{
975
975
if (!File.Exists(path) && !Directory.Exists(path))
976
976
{
977
977
Puts($"The path '{path}' does not exist.");
978
978
return;
979
979
}
980
980
ProcessStartInfo psi = new ProcessStartInfo
981
981
{
982
982
FileName = "chmod",
983
983
Arguments = $"{permissions} \"{path}\"",
984
984
RedirectStandardOutput = true,
985
985
RedirectStandardError = true,
986
986
UseShellExecute = false,
987
987
CreateNoWindow = true
988
988
};
989
989
using (Process process = Process.Start(psi))
990
990
{
991
991
if (process != null)
992
992
{
993
993
string output = process.StandardOutput.ReadToEnd();
994
994
string error = process.StandardError.ReadToEnd();
995
995
process.WaitForExit();
996
996
if (process.ExitCode != 0)
997
997
{
998
998
Puts($"chmod failed with error: {error}");
999
999
return;
1000
1000
}
1001
1001
Puts($"chmod output: {output}");
1002
1002
}
1003
1003
}
1004
1004
}
1005
1005
1006
1006
//Find boombox thats being held or looked at
1007
1007
private BaseEntity FindBoomBox(BasePlayer player)
1008
1008
{
1009
1009
if (player.IsHoldingEntity<HeldBoomBox>()) { return player?.GetActiveItem()?.GetHeldEntity(); }
1010
1010
//Check player has building perm to stop changing other bases deployed boomboxes
1011
1011
if (!player.CanPlaceBuildingPrivilege() && !player.IsAdmin) { player.ChatMessage("<color=red>You don't have building permission.</color>"); return null; }
1012
1012
//Check what player is looking at
1013
1013
RaycastHit rhit;
1014
1014
if (Physics.Raycast(player.eyes.HeadRay(), out rhit))
1015
1015
{
1016
1016
var baseEntity = rhit.GetEntity();
1017
1017
if (baseEntity != null && rhit.distance < 5f && baseEntity is DeployableBoomBox) { return baseEntity; }
1018
1018
}
1019
1019
player.ChatMessage("<color=red>Couldn't find boombox deployed or held.</color>");
1020
1020
return null;
1021
1021
}
1022
1022
1023
1023
//Check for missing tapes since one missing tape will break the whole lot
1024
1024
public void CleanUpMissingTapes()
1025
1025
{
1026
1026
if (config.AutoClean)
1027
1027
{
1028
1028
for (int i = config.CustomCassettes.Count - 1; i >= 0; i--)
1029
1029
{
1030
1030
bool missing = false;
1031
1031
foreach (uint u in config.CustomCassettes[i].netids)
1032
1032
{
1033
1033
BaseNetworkable bn = BaseNetworkable.serverEntities.Find(new NetworkableId(u));
1034
1034
if (bn == null)
1035
1035
{
1036
1036
missing = true;
1037
1037
break;
1038
1038
}
1039
1039
}
1040
1040
if (missing)
1041
1041
{
1042
1042
//Remove set if one is missing
1043
1043
Puts("Missing Cassettes detected " + config.CustomCassettes[i].title);
1044
1044
ServerMgr.Instance.StartCoroutine(RemoveCassettes(null, config.CustomCassettes[i].ID));
1045
1045
}
1046
1046
}
1047
1047
}
1048
1048
}
1049
1049
1050
1050
//Check player cool down to prevent command spam
1051
1051
private void cooldown(BasePlayer player, string url)
1052
1052
{
1053
1053
if (player != null)
1054
1054
{
1055
1055
if (config.debug) { Puts(player.displayName + " Played: " + url); }
1056
1056
if (cooldowns.ContainsKey(player.userID)) { cooldowns[player.userID] = Time.time; }
1057
1057
else { cooldowns.Add(player.userID, Time.time); }
1058
1058
}
1059
1059
}
1060
1060
1061
1061
//Directly set audio player for API calls
1062
1062
public void ManuallySetRadio(BaseEntity be, string url)
1063
1063
{
1064
1064
if (be != null && (be is HeldBoomBox || be is DeployableBoomBox))
1065
1065
{
1066
1066
//Set Mp3 Link
1067
1067
if (be is HeldBoomBox) { ChangeStationPortable(be as HeldBoomBox, url); }
1068
1068
if (be is DeployableBoomBox) { ChangeStationDeployed(be as DeployableBoomBox, url); }
1069
1069
if (config.debug) { Puts("Server played " + url); }
1070
1070
}
1071
1071
}
1072
1072
1073
1073
//Adjust mp3 link on held boombox
1074
1074
private void ChangeStationPortable(HeldBoomBox portableradio, string newlink, BasePlayer player = null)
1075
1075
{
1076
1076
portableradio.BoxController.CurrentRadioIp = newlink;
1077
1077
portableradio.BoxController.ServerTogglePlay(false);
1078
1078
portableradio.BoxController.BaseEntity.ClientRPC(RpcTarget.NetworkGroup("OnRadioIPChanged"), portableradio.BoxController.CurrentRadioIp);
1079
1079
portableradio.BoxController.ServerTogglePlay(true);
1080
1080
cooldown(player, newlink);
1081
1081
if (config.debug) { Puts("Playing Custom Station"); }
1082
1082
}
1083
1083
1084
1084
//Adjust mp3 link on deployed boombox
1085
1085
private void ChangeStationDeployed(DeployableBoomBox radio, string newlink, BasePlayer player = null)
1086
1086
{
1087
1087
radio.BoxController.CurrentRadioIp = newlink;
1088
1088
radio.BoxController.BaseEntity.ClientRPC(RpcTarget.NetworkGroup("OnRadioIPChanged"), radio.BoxController.CurrentRadioIp);
1089
1089
radio.BoxController.ServerTogglePlay(true);
1090
1090
cooldown(player, newlink);
1091
1091
if (config.debug) { Puts("Playing Custom Station"); }
1092
1092
}
1093
1093
1094
1094
//Write info to cassette
1095
1095
public Cassette SetCassette(string title, byte[] data, BasePlayer player, float length, ref Item item)
1096
1096
{
1097
1097
Cassette cassetteEntity;
1098
1098
if ((cassetteEntity = (BaseNetworkable.serverEntities.Find(item.instanceData.subEntity) as Cassette)) != null)
1099
1099
{
1100
1100
//cassetteEntity.ClearContent();
1101
1101
item.text = title;
1102
1102
ulong UserID = 0;
1103
1103
if (player != null) { UserID = player.userID; }
1104
1104
cassetteEntity.CreatorSteamId = UserID;
1105
1105
cassetteEntity.MaxCassetteLength = length;
1106
1106
//Store data in file database
1107
1107
cassetteEntity.SetAudioId(FileStorage.server.Store(data, FileStorage.Type.ogg, cassetteEntity.net.ID, 0U), UserID);
1108
1108
cassetteEntity.skinID = item.skin;
1109
1109
}
1110
1110
return cassetteEntity;
1111
1111
}
1112
1112
1113
1113
//Update entity with changes
1114
1114
public void UpdateEntity(BaseEntity entity, Cassette newcassette, Item newcassetteitem)
1115
1115
{
1116
1116
if (entity != null && newcassette != null)
1117
1117
{
1118
1118
if (entity is DeployedRecorder)
1119
1119
{
1120
1120
if (newcassetteitem != null) { newcassetteitem.MoveToContainer((entity as DeployedRecorder).inventory); }
1121
1121
DeployedRecorder(newcassette, entity as DeployedRecorder);
1122
1122
return;
1123
1123
}
1124
1124
if (entity is DeployableBoomBox)
1125
1125
{
1126
1126
if (newcassetteitem != null) { newcassetteitem.MoveToContainer((entity as DeployableBoomBox).inventory); }
1127
1127
BoomBox(newcassette, (entity as DeployableBoomBox).BoxController);
1128
1128
return;
1129
1129
}
1130
1130
}
1131
1131
}
1132
1132
1133
1133
//Play tape on Deployed recorder
1134
1134
public bool DeployedRecorder(Cassette c, DeployedRecorder baseentity)
1135
1135
{
1136
1136
if (!baseentity || c.skinID == 3367283489) { return false; } //Dont play grey tapes
1137
1137
//Stop anr existing play threads
1138
1138
if (TapeThreads.ContainsKey(baseentity))
1139
1139
{
1140
1140
if (TapeThreads[baseentity] != null) { ServerMgr.Instance.StopCoroutine(TapeThreads[baseentity]); }
1141
1141
TapeThreads.Remove(baseentity);
1142
1142
}
1143
1143
//Find first tape in list
1144
1144
var results = config.CustomCassettes.Where(x => x.netids.Contains(c.net.ID.Value));
1145
1145
if (results == null || results.Count() == 0) { return true; }
1146
-
baseentity.SetFlag(BaseEntity.Flags.On, true, false, true);
1146
+
baseentity.SetFlagLocal(BaseEntity.Flags.On, true, false);
1147
1147
TapeThreads.Add(baseentity, ServerMgr.Instance.StartCoroutine(CycleTapes(baseentity, results.SingleOrDefault())));
1148
1148
return false;
1149
1149
}
1150
1150
1151
1151
//Play tape in boombox
1152
1152
public bool BoomBox(Cassette c, BoomBox baseentity)
1153
1153
{
1154
1154
if (!baseentity || c.skinID == 3367283489) { return false; } //Dont play grey tapes
1155
1155
//Stop any existing play threads
1156
1156
if (TapeThreads.ContainsKey(baseentity.BaseEntity))
1157
1157
{
1158
1158
if (TapeThreads[baseentity.BaseEntity] != null) { ServerMgr.Instance.StopCoroutine(TapeThreads[baseentity.BaseEntity]); }
1159
1159
TapeThreads.Remove(baseentity.BaseEntity);
1160
1160
}
1161
1161
//Find first tape in list
1162
1162
var results = config.CustomCassettes.Where(x => x.netids.Contains(c.net.ID.Value));
1163
1163
if (results == null || results.Count() == 0) { return true; }
1164
1164
baseentity.ServerTogglePlay(true);
1165
1165
TapeThreads.Add(baseentity.BaseEntity, ServerMgr.Instance.StartCoroutine(CycleTapes(baseentity.BaseEntity, results.SingleOrDefault())));
1166
1166
return false;
1167
1167
}
1168
1168
1169
1169
public void UpdateSpeakers(IOEntity ioentity)
1170
1170
{
1171
1171
if (ioentity != null)
1172
1172
{
1173
1173
//Update all connected Speakers
1174
1174
IOEntity NextIOEnt = ioentity?.outputs[0]?.connectedTo?.Get();
1175
1175
int depth = 0;
1176
1176
List<ConnectedSpeaker> Speakers = Facepunch.Pool.Get<List<ConnectedSpeaker>>();
1177
1177
Speakers.Clear(); //Make sure isnt dirty from delay
1178
1178
while (NextIOEnt != null && depth < 90) //Max power out of boombox is 90 so that should be max connected
1179
1179
{
1180
1180
depth++;
1181
1181
if (NextIOEnt is ConnectedSpeaker)
1182
1182
{
1183
1183
//Stop speaker playing to clear audio from it
1184
1184
NextIOEnt.ClientRPC(RpcTarget.NetworkGroup("Client_StopPlayingAudio"), ioentity.net.ID);
1185
1185
NextIOEnt.SendNetworkUpdateImmediate();
1186
1186
Speakers.Add(NextIOEnt as ConnectedSpeaker);
1187
1187
}
1188
1188
NextIOEnt = NextIOEnt?.outputs[0]?.connectedTo?.Get(); //Get next IO in the path
1189
1189
}
1190
1190
ioentity.SendNetworkUpdateImmediate();
1191
1191
ioentity.Invoke(() =>
1192
1192
{
1193
1193
if (Speakers != null && ioentity != null)
1194
1194
{
1195
1195
//Start speaker again
1196
1196
foreach (ConnectedSpeaker speaker in Speakers)
1197
1197
{
1198
1198
speaker.ClientRPC(RpcTarget.NetworkGroup("Client_PlayAudioFrom"), ioentity.net.ID);
1199
1199
speaker.SendNetworkUpdateImmediate();
1200
1200
}
1201
1201
}
1202
1202
Facepunch.Pool.FreeUnmanaged(ref Speakers);
1203
1203
}, 0.03125f);
1204
1204
}
1205
1205
}
1206
1206
#endregion
1207
1207
1208
1208
#region Coroutines
1209
1209
public IEnumerator RemoveCassettes(BasePlayer player, ulong index, bool RefreshCUI = false, string url = null)
1210
1210
{
1211
1211
for (int i = config.CustomCassettes.Count - 1; i >= 0; i--)
1212
1212
{
1213
1213
//Player null must be from plugin, Other wise limit to players own cassets or all cassettes if admin
1214
1214
if (player == null || (player != null && player.IsAdmin || config.CustomCassettes[i].ownerid == player?.userID))
1215
1215
{
1216
1216
if (config.CustomCassettes[i].ID == index)
1217
1217
{
1218
1218
//Free File Store Memory
1219
1219
for (int c = config.CustomCassettes[i].netids.Count - 1; c >= 0; c--)
1220
1220
{
1221
1221
try { FileStorage.server.Remove(config.CustomCassettes[i].crc[c], FileStorage.Type.ogg, new NetworkableId(config.CustomCassettes[i].netids[c])); } catch { }
1222
1222
}
1223
1223
//Remove from players inventory
1224
1224
foreach (BasePlayer p in BasePlayer.allPlayerList)
1225
1225
{
1226
1226
1227
1227
List<Item> items = Facepunch.Pool.Get<List<Item>>();
1228
1228
try
1229
1229
{
1230
1230
p.inventory.GetAllItems(items);
1231
1231
if (items != null && items.Count > 0) for (int j = 0; j < items.Count; j++) { try { if (config.CustomCassettes[i].netids != null) if (config.CustomCassettes[i].netids.Contains(items[j].instanceData.subEntity.Value)) { items[j].Remove(); } } catch { } }
1232
1232
//Check backpack
1233
1233
if (p.inventory.HasBackpackItem())
1234
1234
{
1235
1235
Item anyBackpack = p.inventory.GetAnyBackpack();
1236
1236
if (anyBackpack.contents.itemList != null && anyBackpack.contents.itemList.Count > 0) for (int j = 0; j < anyBackpack.contents.itemList.Count; j++) { try { if (config.CustomCassettes[i].netids != null) if (config.CustomCassettes[i].netids.Contains(anyBackpack.contents.itemList[j].instanceData.subEntity.Value)) { anyBackpack.contents.itemList[j].Remove(); } } catch { } }
1237
1237
}
1238
1238
}
1239
1239
catch { }
1240
1240
Facepunch.Pool.FreeUnmanaged(ref items);
1241
1241
}
1242
1242
//Search all server ents
1243
1243
int entloops = 0;
1244
1244
foreach (var bn in BaseNetworkable.serverEntities?.entityList?.Get())
1245
1245
{
1246
1246
if (bn.Value == null) { continue; }
1247
1247
entloops++;
1248
1248
//Return thread every 10000 ents checked to prevent thread lock
1249
1249
if (entloops > 10000)
1250
1250
{
1251
1251
entloops = 0;
1252
1252
yield return CoroutineEx.waitForSeconds(0.0366f);
1253
1253
}
1254
1254
//Remove dropped cassettes
1255
1255
if (bn.Value is DroppedItem)
1256
1256
{
1257
1257
var item = (bn.Value as DroppedItem)?.item ?? null;
1258
1258
if (item == null) { continue; }
1259
1259
try { if (config.CustomCassettes[i].netids != null && config.CustomCassettes[i].netids.Contains(item.instanceData.subEntity.Value)) { item.Remove(); } } catch { }
1260
1260
continue;
1261
1261
}
1262
1262
//Check all containers for cassettes to remove
1263
1263
if (bn.Value is StorageContainer)
1264
1264
{
1265
1265
var container = (bn.Value as StorageContainer)?.inventory?.itemList ?? null;
1266
1266
if (container == null) { continue; }
1267
1267
for (int j = 0; j < container.Count; j++)
1268
1268
{
1269
1269
try { if (config.CustomCassettes[i].netids != null) if (config.CustomCassettes[i].netids.Contains(container[j].instanceData.subEntity.Value)) { container[j].Remove(); } } catch { }
1270
1270
}
1271
1271
continue;
1272
1272
}
1273
1273
//Check audio players for cassettes to remove
1274
1274
if (bn.Value is DeployableBoomBox || bn.Value is DeployedRecorder || bn.Value is HeldBoomBox)
1275
1275
{
1276
1276
var container = (bn.Value as DeployableBoomBox)?.inventory?.itemList ?? null;
1277
1277
if (container == null) { container = (bn.Value as DeployedRecorder)?.inventory?.itemList ?? null; }
1278
1278
if (container == null) { container = (bn.Value as HeldBoomBox)?.GetItem()?.contents?.itemList; }
1279
1279
if (container == null) { continue; }
1280
1280
for (int j = 0; j < container.Count; j++)
1281
1281
{
1282
1282
try { if (config.CustomCassettes[i].netids != null) if (config.CustomCassettes[i].netids.Contains(container[j].instanceData.subEntity.Value)) { container[j].Remove(); } } catch { }
1283
1283
}
1284
1284
continue;
1285
1285
}
1286
1286
//Remove from ent list
1287
1287
if (config.CustomCassettes[i].netids.Contains(bn.Value.net.ID.Value)) { bn.Value.Kill(); }
1288
1288
}
1289
1289
string title = config?.CustomCassettes[i]?.title;
1290
1290
ItemManager.DoRemoves();
1291
1291
if (config?.CustomCassettes[i] != null)
1292
1292
{
1293
1293
config.CustomCassettes.RemoveAt(i);
1294
1294
Config.WriteObject(config, true);
1295
1295
}
1296
1296
try
1297
1297
{
1298
1298
if (player != null)
1299
1299
{
1300
1300
player.ConsoleMessage("Removed: " + title);
1301
1301
player.ChatMessage("Removed: " + title);
1302
1302
if (RefreshCUI)
1303
1303
{
1304
1304
CuiHelper.AddUi(player, RustUI(player, 0, url, CUICheckSum));
1305
1305
CuiHelper.AddUi(player, CassetteListUI(player, CUICheckSum));
1306
1306
}
1307
1307
}
1308
1308
else { Puts("Removed: " + title); }
1309
1309
}
1310
1310
catch { }
1311
1311
yield break;
1312
1312
}
1313
1313
}
1314
1314
}
1315
1315
yield break;
1316
1316
}
1317
1317
1318
1318
//Queue to limit to one user at a time
1319
1319
public IEnumerator QueueChecker()
1320
1320
{
1321
1321
int timeout = 0;
1322
1322
int messager = 0;
1323
1323
while (true)
1324
1324
{
1325
1325
int count;
1326
1326
lock (_queueLock) { count = _queue.Count; }
1327
1327
if (count == 0) { break; } //Nothing left in queue
1328
1328
1329
1329
if (timeout > config.TimeOut) //Timeout
1330
1330
{
1331
1331
timeout = 0;
1332
1332
lock (_queueLock) { if (_queue.Count > 0) { _queue.RemoveAt(0); } }
1333
1333
}
1334
1334
else
1335
1335
{
1336
1336
TapeQueue tapeQueue;
1337
1337
lock (_queueLock) { tapeQueue = _queue.Count > 0 ? _queue[0] : null; }
1338
1338
if (tapeQueue == null) { messager++; timeout++; yield return new WaitForSeconds(1); continue; }
1339
1339
switch (tapeQueue.Status)
1340
1340
{
1341
1341
case 0: //Idle
1342
1342
timeout = 0;
1343
1343
tapeQueue.Status = 1;
1344
1344
ServerMgr.Instance.StartCoroutine(MediaInfo(tapeQueue));
1345
1345
break;
1346
1346
case 1: //Getting Youtube Info
1347
1347
break;
1348
1348
case 2: //Got Info, Download and reset timeout
1349
1349
timeout = 0;
1350
1350
tapeQueue.Status = 3;
1351
1351
break;
1352
1352
case 3: //Downloading
1353
1353
break;
1354
1354
case 4: //Downloaded, Encode and reset timeout
1355
1355
timeout = 0;
1356
1356
tapeQueue.Status = 5;
1357
1357
break;
1358
1358
case 5: //Encoding
1359
1359
break;
1360
1360
case 6: //Done Remove From Queue
1361
1361
lock (_queueLock) { _queue.Remove(tapeQueue); }
1362
1362
break;
1363
1363
}
1364
1364
if (messager > config.MessageTimeout) //Message Waiting Players
1365
1365
{
1366
1366
lock (_queueLock)
1367
1367
{
1368
1368
if (_queue.Count > 1)
1369
1369
{
1370
1370
for (int i = 1; i < _queue.Count; i++)
1371
1371
{
1372
1372
if (_queue[i].Player != null) { _queue[i].Player.ConsoleMessage("You are " + i + " / " + _queue.Count + " in download queue"); }
1373
1373
}
1374
1374
}
1375
1375
}
1376
1376
messager = 0;
1377
1377
}
1378
1378
}
1379
1379
messager++;
1380
1380
timeout++;
1381
1381
yield return new WaitForSeconds(1);
1382
1382
}
1383
1383
_queuechecker = null;
1384
1384
}
1385
1385
1386
1386
//Download Youtube video info
1387
1387
public IEnumerator MediaInfo(TapeQueue tapeQueue)
1388
1388
{
1389
1389
MediaInfoData info = null;
1390
1390
if (tapeQueue.Player != null)
1391
1391
{
1392
1392
tapeQueue.Player.ChatMessage("<color=red>Getting YouTube audio check F1 for more details.</color>");
1393
1393
tapeQueue.Player.ConsoleMessage(VersionString);
1394
1394
tapeQueue.Player.ConsoleMessage("Waiting For Youtube Info...");
1395
1395
}
1396
1396
//Run in thread to prevent thread locking since can be slow waiting on youtube
1397
1397
Task task = Task.Run(() =>
1398
1398
{
1399
1399
var process = codebase.runCommand(Path.Combine(installPath, YouTubeDL.binName), (config.PublicCookies ? "--cookies " + Path.Combine(installPath, "cookies.txt") + " " : !string.IsNullOrEmpty(config.CookiesPath) ? "--cookies " + config.CookiesPath + " " : "") + "-s --no-warnings --no-cache-dir --remote-components ejs:github --print-json " + tapeQueue.Url);
1400
1400
process.Start();
1401
1401
process.PriorityClass = ProcessPriorityClass.Idle;
1402
1402
string json = process.StandardOutput.ReadToEnd();
1403
1403
if (json.Contains("ERROR: [youtube]"))
1404
1404
{
1405
1405
if (tapeQueue.Player != null)
1406
1406
{
1407
1407
tapeQueue.Player.ConsoleMessage(json);
1408
1408
tapeQueue.Player.ChatMessage("<color=red>Failed To Download Youtube Info</color>");
1409
1409
}
1410
1410
lock (_queueLock) { _queue.Remove(tapeQueue); }
1411
1411
return;
1412
1412
}
1413
1413
info = JsonConvert.DeserializeObject<MediaInfoData>(json);
1414
1414
if (string.IsNullOrEmpty(info?.title))
1415
1415
{
1416
1416
if (tapeQueue.Player != null)
1417
1417
{
1418
1418
tapeQueue.Player.ConsoleMessage("Failed To Download Youtube Info");
1419
1419
tapeQueue.Player.ChatMessage("<color=red>Failed To Download Youtube Info</color>");
1420
1420
}
1421
1421
lock (_queueLock) { _queue.Remove(tapeQueue); }
1422
1422
return;
1423
1423
}
1424
1424
//Check info doesnt list as longer then configs max duration
1425
1425
double durationParsed;
1426
1426
if (!double.TryParse(info.duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out durationParsed))
1427
1427
{
1428
1428
if (tapeQueue.Player != null)
1429
1429
{
1430
1430
tapeQueue.Player.ConsoleMessage("Could not determine video length (unsupported/live video?)");
1431
1431
tapeQueue.Player.ChatMessage("<color=red>Could not determine video length.</color>");
1432
1432
}
1433
1433
lock (_queueLock) { _queue.Remove(tapeQueue); }
1434
1434
return;
1435
1435
}
1436
1436
int duration = (int)durationParsed;
1437
1437
if (duration > config.MaxVideoLength)
1438
1438
{
1439
1439
if (tapeQueue.Player != null)
1440
1440
{
1441
1441
string err = "File length too long [" + info.duration + "/" + config.MaxVideoLength + "]";
1442
1442
tapeQueue.Player.ConsoleMessage(err);
1443
1443
tapeQueue.Player.ChatMessage(err);
1444
1444
}
1445
1445
lock (_queueLock) { _queue.Remove(tapeQueue); }
1446
1446
return;
1447
1447
}
1448
1448
if (tapeQueue.Player != null)
1449
1449
{
1450
1450
string msg = "Downloading: " + info.title + " [" + duration + "s]";
1451
1451
tapeQueue.Player.ChatMessage(msg);
1452
1452
tapeQueue.Player.ConsoleMessage(msg);
1453
1453
cooldown(tapeQueue.Player, tapeQueue.Url);
1454
1454
}
1455
1455
process = runCommand(Path.Combine(installPath, YouTubeDL.binName), YouTubeDL.DefaultArgs(installPath) + (config.UseExtraArgs ? config.ExtraArgs : "") + tapeQueue.Url);
1456
1456
YT_DLP.Add(ServerMgr.Instance.StartCoroutine(MakeCassette(process, tapeQueue, null, info)));
1457
1457
});
1458
1458
yield break;
1459
1459
}
1460
1460
1461
1461
//Switch tape to next one in the list on completion of a tape
1462
1462
public IEnumerator CycleTapes(BaseEntity cassetplayer, CassetteParts parts)
1463
1463
{
1464
1464
int track = 0;
1465
1465
while (cassetplayer.HasFlag(BaseEntity.Flags.On))
1466
1466
{
1467
1467
if (track == parts.netids.Count) { track = 0; }
1468
1468
var cid = new NetworkableId(parts.netids[track]);
1469
1469
cassetplayer.ClientRPC(RpcTarget.NetworkGroup("Client_OnCassetteInserted"), cid);
1470
1470
cassetplayer.SendNetworkUpdate(BasePlayer.NetworkQueue.Update);
1471
1471
cassetplayer.Invoke(() => { UpdateSpeakers(cassetplayer as IOEntity); }, 0.03125f);
1472
1472
yield return CoroutineEx.waitForSeconds(parts.lengths[track++]);
1473
1473
}
1474
1474
}
1475
1475
1476
1476
//Create multiple tapes from a single audio file
1477
1477
public IEnumerator EncodeChapter(Item Cassette, float playlength, string source, TapeQueue tapeQueue, BaseEntity entity, MediaInfoData mediaInfo)
1478
1478
{
1479
1479
bool parent = true;
1480
1480
CassetteParts newcassette = new CassetteParts();
1481
1481
newcassette.title = mediaInfo.title;
1482
1482
if (tapeQueue != null) { tapeQueue.Player.ConsoleMessage("Encoding " + mediaInfo.title); }
1483
1483
//Split into parts upto 30 sec each
1484
1484
int[] Chapters = DistributeIntoGroups(playlength, config.cassetlength);
1485
1485
Item newcassetteitem = null;
1486
1486
Cassette newServercassette = null;
1487
1487
for (int i = 0; i < Chapters.Length; i++)
1488
1488
{
1489
1489
tapeQueue.Status = 4;
1490
1490
if (tapeQueue != null) { tapeQueue.Player.ConsoleMessage("[encoding] " + (i + 1) + " / " + Chapters.Length); }
1491
1491
//Run FFMPEG to split into parts
1492
1492
string filename = source.Replace(".ogg", "." + i.ToString() + ".ogg");
1493
1493
Process process = FFmpeg.run("-nostdin -hide_banner -ss " + Chapters[i].ToString() + " -t " + config.cassetlength + @" -i """ + source + @""" """ + filename + @"""");
1494
1494
process.Start();
1495
1495
process.PriorityClass = ProcessPriorityClass.Idle;
1496
1496
Task.Run(() =>
1497
1497
{
1498
1498
try { while (!process.StandardOutput.EndOfStream) { process.StandardOutput.ReadLine(); } }
1499
1499
catch { }
1500
1500
});
1501
1501
while (!process.StandardError.EndOfStream)
1502
1502
{
1503
1503
//Small wait for disk IO
1504
1504
yield return CoroutineEx.waitForSeconds(0.003f);
1505
1505
string console = process.StandardError.ReadLine();
1506
1506
if (config.debug) { Puts(console); }
1507
1507
//FFMPEG Conversion Done
1508
1508
if (console.Contains("video:"))
1509
1509
{
1510
1510
//Wait for IO to write to disk
1511
1511
yield return CoroutineEx.waitForSeconds(0.25f);
1512
1512
if (File.Exists(filename))
1513
1513
{
1514
1514
//Found split part of audio on disk, Write it to cassette
1515
1515
byte[] oggfile = File.ReadAllBytes(filename);
1516
1516
float sectionlength = 0;
1517
1517
Task task = Task.Run(() => { sectionlength = GetOggLength(oggfile); });
1518
1518
//Wait for getting ogg length with out locking thread
1519
1519
while (!task.IsCompleted) { yield return CoroutineEx.waitForSeconds(0.01f); }
1520
1520
if (parent) { newcassetteitem = Cassette; }
1521
1521
else { newcassetteitem = ItemManager.Create(ItemManager.FindItemDefinition("cassette"), 1, 3367283489); } //Skin as grey tape
1522
1522
newServercassette = SetCassette(mediaInfo.title, oggfile, tapeQueue.Player, sectionlength, ref newcassetteitem);
1523
1523
if (!parent) { if (tapeQueue != null) { tapeQueue.Player.GiveItem(newcassetteitem, BaseEntity.GiveItemReason.Crafted); } }
1524
1524
else { parent = false; }
1525
1525
newcassette.crc.Add(newServercassette.AudioId);
1526
1526
newcassette.netids.Add(newcassetteitem.instanceData.subEntity.Value);
1527
1527
newcassette.lengths.Add((int)sectionlength);
1528
1528
File.Delete(filename); //Remove temp part file
1529
1529
continue;
1530
1530
}
1531
1531
}
1532
1532
}
1533
1533
}
1534
1534
//Setup custom cassette and give to player
1535
1535
newcassette.ID = newServercassette.net.ID.Value;
1536
1536
if (tapeQueue != null)
1537
1537
{
1538
1538
newcassette.ownerid = tapeQueue.Player.userID;
1539
1539
tapeQueue.Player.GiveItem(Cassette, BaseEntity.GiveItemReason.Crafted);
1540
1540
}
1541
1541
config.CustomCassettes.Add(newcassette);
1542
1542
Config.WriteObject(config, true);
1543
1543
UpdateEntity(entity, newServercassette, newcassetteitem);
1544
1544
if (tapeQueue != null) { tapeQueue.Status = 6; }
1545
1545
File.Delete(source); //Remove Source file
1546
1546
yield break;
1547
1547
}
1548
1548
1549
1549
public IEnumerator MakeCassette(Process process, TapeQueue tapeQueue, BaseEntity entity = null, MediaInfoData mediaInfo = null)
1550
1550
{
1551
1551
tapeQueue.Status = 2;
1552
1552
Task task = Task.Run(() =>
1553
1553
{
1554
1554
//Create default item to use as main tape
1555
1555
Item DefaultCassette = ItemManager.Create(ItemManager.FindItemDefinition("cassette"), 1, 0);
1556
1556
//Start Youtube Downloader
1557
1557
process.Start();
1558
1558
//Set as lowest priority so doesnt effect rust server
1559
1559
process.PriorityClass = ProcessPriorityClass.Idle;
1560
1560
//Drain stderr on its own thread - if nobody reads it, the OS pipe buffer fills up once
1561
1561
//yt-dlp writes enough warnings/notices there, and yt-dlp then blocks trying to write to
1562
1562
//it, silently stalling the whole process (including stdout). This is what causes
1563
1563
//downloads to appear to hang with no further output.
1564
1564
Task.Run(() =>
1565
1565
{
1566
1566
try
1567
1567
{
1568
1568
while (!process.StandardError.EndOfStream)
1569
1569
{
1570
1570
string errLine = process.StandardError.ReadLine();
1571
1571
if (config.debug && !string.IsNullOrEmpty(errLine)) { Puts("[yt-dlp stderr] " + errLine); }
1572
1572
}
1573
1573
}
1574
1574
catch { }
1575
1575
});
1576
1576
//Loop until has all downloaded data
1577
1577
while (!process.StandardOutput.EndOfStream)
1578
1578
{
1579
1579
//Read console output
1580
1580
string console = process.StandardOutput.ReadLine();
1581
1581
if (config.debug) { Puts(console); }
1582
1582
//Gotten info on download, update console
1583
1583
if (console.Contains("[download]"))
1584
1584
{
1585
1585
if (tapeQueue != null && !console.Contains("Destination")) { tapeQueue.Player.ConsoleMessage(console); }
1586
1586
continue;
1587
1587
}
1588
1588
//Temp file full download and converted, Start converting to tapes
1589
1589
if (console.Contains("Deleting original file"))
1590
1590
{
1591
1591
string filename = console.Replace("Deleting original file ", "").Replace(".webm (pass -k to keep)", ".ogg").Replace(".m4a (pass -k to keep)", ".ogg").Replace(@"\", @"\\");
1592
1592
if (config.debug) { Puts("File Name: " + filename); }
1593
1593
if (File.Exists(filename))
1594
1594
{
1595
1595
if (config.debug) { Puts("Found File: " + filename); }
1596
1596
byte[] oggfile = null;
1597
1597
float playlength = 0;
1598
1598
//Read data out of temp folder
1599
1599
oggfile = File.ReadAllBytes(filename); playlength = GetOggLength(oggfile);
1600
1600
if (config.debug) { Puts("Play Length: " + playlength); }
1601
1601
if (playlength > config.cassetlength)
1602
1602
{
1603
1603
//Split up into parts
1604
1604
YT_DLP.Add(ServerMgr.Instance.StartCoroutine(EncodeChapter(DefaultCassette, playlength, Path.Combine(Environment.CurrentDirectory, filename), tapeQueue, entity, mediaInfo)));
1605
1605
break;
1606
1606
}
1607
1607
tapeQueue.Status = 4;
1608
1608
//Smaller then 30sec so single tape
1609
1609
CassetteParts newcassette = new CassetteParts();
1610
1610
newcassette.title = mediaInfo.title;
1611
1611
Cassette newServercassette = SetCassette(filename, oggfile, tapeQueue?.Player, playlength, ref DefaultCassette);
1612
1612
if (tapeQueue != null) { tapeQueue.Player.GiveItem(DefaultCassette, BaseEntity.GiveItemReason.Crafted); }
1613
1613
if (config.debug) { Puts("Cassette Created: " + newcassette.ToString() + " Deleting download."); }
1614
1614
newcassette.crc.Add(newServercassette.AudioId);
1615
1615
newcassette.netids.Add(newServercassette.net.ID.Value);
1616
1616
newcassette.lengths.Add((int)playlength);
1617
1617
newcassette.ID = newServercassette.net.ID.Value;
1618
1618
config.CustomCassettes.Add(newcassette);
1619
1619
Config.WriteObject(config, true);
1620
1620
UpdateEntity(entity, newServercassette, DefaultCassette);
1621
1621
if (tapeQueue != null) { tapeQueue.Status = 6; }
1622
1622
File.Delete(filename);
1623
1623
break;
1624
1624
}
1625
1625
}
1626
1626
}
1627
1627
});
1628
1628
yield break;
1629
1629
}
1630
1630
#endregion
1631
1631
1632
1632
#region Classes
1633
1633
internal class YouTubeDL
1634
1634
{
1635
1635
public static string binName;
1636
1636
public static string downloadURL;
1637
1637
1638
1638
//Args to extra youtube audio
1639
1639
public static string DefaultArgs(string path) { return (codebase.config.PublicCookies ? "--cookies " + Path.Combine(codebase.installPath, "cookies.txt") + " " : !string.IsNullOrEmpty(codebase.config.CookiesPath) ? "--cookies " + codebase.config.CookiesPath + " " : "") + @"-x --audio-format vorbis --audio-quality 10 --postprocessor-args ""-ac 1"" --restrict-filenames --remote-components ejs:github -o " + Path.Combine(path, "dl", @"%(title)s.%(id)s.%(ext)s") + @" --ffmpeg-location """ + path + @""" "; }
1640
1640
public static bool isInstalled() { return File.Exists(Path.Combine(codebase.installPath, binName)); } //Check if installed by if files exsist
1641
1641
public static void checkDownload() { if (!isInstalled()) { downloadAndInstall(); } } //Check if need to download
1642
1642
public static void downloadAndInstall()
1643
1643
{
1644
1644
//Create folder if missing
1645
1645
if (!File.Exists(Path.Combine(codebase.installPath, binName)))
1646
1646
{
1647
1647
if (!Directory.Exists(codebase.installPath)) { Directory.CreateDirectory(codebase.installPath); }
1648
1648
try { using (WebClient wc = new WebClient()) { wc.DownloadFile(new Uri(downloadURL), Path.Combine(codebase.installPath, binName)); } }
1649
1649
catch (Exception ex) { codebase.Puts("Failed to download yt-dlp: " + ex.Message); return; }
1650
1650
}
1651
1651
//Set up permission on Linux
1652
1652
if (codebase.Linux)
1653
1653
{
1654
1654
codebase.Puts("Setting YouTubeDL Permissions");
1655
1655
codebase.SetPermissions(Path.Combine(codebase.installPath, binName), "777");
1656
1656
}
1657
1657
}
1658
1658
1659
1659
//Check if latest version
1660
1660
//Re-downloads the latest yt-dlp release and reports if the version changed. yt-dlp ships
1661
1661
//frequent fixes for YouTube site changes, so the binary needs to actually be refreshed -
1662
1662
//previously this only compared the already-installed binary's version to itself and never
1663
1663
//fetched anything new, so it silently went stale after first install.
1664
1664
public static void checkForUpdates()
1665
1665
{
1666
1666
if (!isInstalled()) { return; }
1667
1667
string lastVersionYouTubeDL = codebase.config.lastVersionYouTubeDL;
1668
1668
try
1669
1669
{
1670
1670
using (WebClient wc = new WebClient()) { wc.DownloadFile(new Uri(downloadURL), Path.Combine(codebase.installPath, binName)); }
1671
1671
}
1672
1672
catch (Exception ex)
1673
1673
{
1674
1674
codebase.Puts("Failed to check for yt-dlp updates: " + ex.Message);
1675
1675
return;
1676
1676
}
1677
1677
if (codebase.Linux) { codebase.SetPermissions(Path.Combine(codebase.installPath, binName), "777"); }
1678
1678
string version = getVersion();
1679
1679
if (string.IsNullOrEmpty(lastVersionYouTubeDL) || lastVersionYouTubeDL != version)
1680
1680
{
1681
1681
codebase.config.lastVersionYouTubeDL = version;
1682
1682
codebase.SaveConfig();
1683
1683
if (!string.IsNullOrEmpty(lastVersionYouTubeDL)) { codebase.Puts(string.Format("Dependency yt-dlp has been upgraded from {0} to {1}!", lastVersionYouTubeDL, version)); }
1684
1684
}
1685
1685
}
1686
1686
1687
1687
//Get Version Number Of Youtube Downloader
1688
1688
public static string getVersion()
1689
1689
{
1690
1690
Process process = codebase.runCommand(Path.Combine(codebase.installPath, binName), "--version");
1691
1691
process.Start();
1692
1692
return process.StandardOutput.ReadToEnd().Trim();
1693
1693
}
1694
1694
}
1695
1695
1696
1696
internal class FFmpeg
1697
1697
{
1698
1698
public static bool isInstalled() { return File.Exists(Path.Combine(codebase.installPath, binName)); }
1699
1699
public static string binName;
1700
1700
public static string downloadURL;
1701
1701
public static Dictionary<string, string> Files; //Extra dependencies
1702
1702
1703
1703
//Download files
1704
1704
public static void downloadAndInstall()
1705
1705
{
1706
1706
if (!File.Exists(Path.Combine(codebase.installPath, binName)))
1707
1707
{
1708
1708
if (!Directory.Exists(codebase.installPath)) { Directory.CreateDirectory(codebase.installPath); }
1709
1709
try
1710
1710
{
1711
1711
using (WebClient wc = new WebClient()) { wc.DownloadFile(new Uri(downloadURL), Path.Combine(codebase.installPath, binName)); }
1712
1712
foreach (KeyValuePair<string, string> f in Files) { using (WebClient wc = new WebClient()) { wc.DownloadFile(new Uri(f.Key), f.Value); } }
1713
1713
}
1714
1714
catch (Exception ex) { codebase.Puts("Failed to download ffmpeg: " + ex.Message); return; }
1715
1715
}
1716
1716
if (codebase.Linux)
1717
1717
{
1718
1718
//Set linux permissions to execute
1719
1719
codebase.Puts("Setting FFmpeg Permissions");
1720
1720
codebase.SetPermissions(Path.Combine(codebase.installPath, binName), "777");
1721
1721
}
1722
1722
}
1723
1723
public static Process run(string opts) { return codebase.runCommand(Path.Combine(codebase.installPath, binName), opts); }
1724
1724
}
1725
1725
1726
1726
//JS runtime yt-dlp needs to solve YouTube's signature/n challenge (its "EJS" system). Without
1727
1727
//it, yt-dlp silently falls back to formats that don't need the challenge solved - usually just
1728
1728
//thumbnails - and audio downloads fail with "Requested format is not available".
1729
1729
internal static class Deno
1730
1730
{
1731
1731
public static bool isInstalled()
1732
1732
{
1733
1733
try
1734
1734
{
1735
1735
ProcessStartInfo psi = new ProcessStartInfo
1736
1736
{
1737
1737
FileName = codebase.WinOS ? "cmd.exe" : "deno",
1738
1738
Arguments = codebase.WinOS ? "/c deno --version" : "--version",
1739
1739
UseShellExecute = false,
1740
1740
RedirectStandardOutput = true,
1741
1741
RedirectStandardError = true,
1742
1742
CreateNoWindow = true
1743
1743
};
1744
1744
1745
1745
using (Process p = new Process { StartInfo = psi })
1746
1746
{
1747
1747
p.Start();
1748
1748
1749
1749
string output = p.StandardOutput.ReadToEnd();
1750
1750
string error = p.StandardError.ReadToEnd();
1751
1751
1752
1752
p.WaitForExit(10000);
1753
1753
1754
1754
if (codebase.config.debug)
1755
1755
{
1756
1756
codebase.Puts("[Deno] ExitCode: " + p.ExitCode);
1757
1757
codebase.Puts("[Deno] Output: " + output.Trim());
1758
1758
1759
1759
if (!string.IsNullOrEmpty(error))
1760
1760
codebase.Puts("[Deno] Error: " + error.Trim());
1761
1761
}
1762
1762
1763
1763
return p.ExitCode == 0;
1764
1764
}
1765
1765
}
1766
1766
catch (Exception ex)
1767
1767
{
1768
1768
if (codebase.config.debug)
1769
1769
codebase.Puts("[Deno] Detection failed: " + ex);
1770
1770
1771
1771
return false;
1772
1772
}
1773
1773
}
1774
1774
1775
1775
//npm's global bin folder is where it just placed the deno binary/shim, and where PATH needs
1776
1776
//to include it. A brand-new terminal picks this up automatically from the system/user PATH,
1777
1777
//but our already-running server process loaded its PATH into memory before npm ever ran, so
1778
1778
//it won't see the new folder until we patch it in ourselves.
1779
1779
private static string GetNpmGlobalBinDir()
1780
1780
{
1781
1781
try
1782
1782
{
1783
1783
ProcessStartInfo psi = codebase.WinOS
1784
1784
? new ProcessStartInfo { FileName = "cmd.exe", Arguments = "/c npm config get prefix" }
1785
1785
: new ProcessStartInfo { FileName = "npm", Arguments = "config get prefix" };
1786
1786
psi.UseShellExecute = false;
1787
1787
psi.RedirectStandardOutput = true;
1788
1788
psi.RedirectStandardError = true;
1789
1789
psi.CreateNoWindow = true;
1790
1790
using (Process p = new Process { StartInfo = psi })
1791
1791
{
1792
1792
p.Start();
1793
1793
string prefix = p.StandardOutput.ReadToEnd().Trim();
1794
1794
p.StandardError.ReadToEnd(); //Drain to avoid the same pipe-buffer deadlock as elsewhere
1795
1795
p.WaitForExit(10000);
1796
1796
if (string.IsNullOrEmpty(prefix)) { return null; }
1797
1797
//On Windows npm places shims directly in the prefix folder; on Unix in <prefix>/bin
1798
1798
return codebase.WinOS ? prefix : Path.Combine(prefix, "bin");
1799
1799
}
1800
1800
}
1801
1801
catch { return null; }
1802
1802
}
1803
1803
1804
1804
//Prepend a directory to THIS process's PATH (in-memory only, not persisted) so anything we
1805
1805
//spawn from here on - including yt-dlp, which itself spawns deno as a child - can find it,
1806
1806
//without requiring the whole server to be restarted.
1807
1807
private static void AddToProcessPath(string dir)
1808
1808
{
1809
1809
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) { return; }
1810
1810
string currentPath = Environment.GetEnvironmentVariable("PATH") ?? "";
1811
1811
string sep = codebase.WinOS ? ";" : ":";
1812
1812
if (!currentPath.Split(new[] { sep }, StringSplitOptions.RemoveEmptyEntries).Any(p => string.Equals(p.TrimEnd('\\', '/'), dir.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase)))
1813
1813
{
1814
1814
Environment.SetEnvironmentVariable("PATH", dir + sep + currentPath);
1815
1815
}
1816
1816
}
1817
1817
1818
1818
//`npm install -g deno` is identical on Windows and Linux, so no OS branching is needed here
1819
1819
//beyond how the shell/host process is invoked. Requires Node.js/npm already present on the
1820
1820
//host; if it isn't, this fails and logs instead of crashing plugin startup.
1821
1821
public static void downloadAndInstall()
1822
1822
{
1823
1823
try
1824
1824
{
1825
1825
ProcessStartInfo psi;
1826
1826
if (codebase.WinOS)
1827
1827
{
1828
1828
//npm ships as npm.cmd on Windows, which needs a shell host to execute directly
1829
1829
psi = new ProcessStartInfo { FileName = "cmd.exe", Arguments = "/c npm install -g deno" };
1830
1830
}
1831
1831
else
1832
1832
{
1833
1833
psi = new ProcessStartInfo { FileName = "npm", Arguments = "install -g deno" };
1834
1834
}
1835
1835
psi.UseShellExecute = false;
1836
1836
psi.RedirectStandardOutput = true;
1837
1837
psi.RedirectStandardError = true;
1838
1838
psi.CreateNoWindow = true;
1839
1839
psi.WindowStyle = ProcessWindowStyle.Hidden;
1840
1840
using (Process process = new Process { StartInfo = psi, EnableRaisingEvents = false })
1841
1841
{
1842
1842
//Read both streams via events instead of synchronous ReadLine loops, so this
1843
1843
//can't deadlock the same way the yt-dlp/ffmpeg process reads could.
1844
1844
process.OutputDataReceived += (s, e) => { if (codebase.config.debug && !string.IsNullOrEmpty(e.Data)) { codebase.Puts("[npm] " + e.Data); } };
1845
1845
process.ErrorDataReceived += (s, e) => { if (codebase.config.debug && !string.IsNullOrEmpty(e.Data)) { codebase.Puts("[npm] " + e.Data); } };
1846
1846
process.Start();
1847
1847
process.BeginOutputReadLine();
1848
1848
process.BeginErrorReadLine();
1849
1849
process.WaitForExit(120000); //npm install can be slow on first run
1850
1850
}
1851
1851
//Patch npm's global bin folder into our own PATH so deno (and anything spawning it,
1852
1852
//like yt-dlp) can be found immediately, without a server restart.
1853
1853
AddToProcessPath(GetNpmGlobalBinDir());
1854
1854
if (isInstalled()) { codebase.Puts("Deno installed successfully."); }
1855
1855
else { codebase.Puts("Deno install via npm did not succeed - is Node.js/npm installed on this host? Install it manually if not: https://deno.com/"); }
1856
1856
}
1857
1857
catch (Exception ex) { codebase.Puts("Failed to install Deno via npm: " + ex.Message + " - install it manually if Node.js/npm isn't available: https://deno.com/"); }
1858
1858
}
1859
1859
}
1860
1860
1861
1861
//Info from youtube downloader thats returned in the json file
1862
1862
internal class MediaInfoData
1863
1863
{
1864
1864
public string duration
1865
1865
{
1866
1866
get { return duration__BackingField; }
1867
1867
set { duration__BackingField = value; }
1868
1868
}
1869
1869
1870
1870
public string title
1871
1871
{
1872
1872
get { return title__BackingField; }
1873
1873
set { title__BackingField = value; }
1874
1874
}
1875
1875
1876
1876
[JsonProperty("_filename")]
1877
1877
public string filename
1878
1878
{
1879
1879
get { return filename__BackingField; }
1880
1880
set { filename__BackingField = value; }
1881
1881
}
1882
1882
1883
1883
private string duration__BackingField;
1884
1884
private string title__BackingField;
1885
1885
private string filename__BackingField;
1886
1886
}
1887
1887
#endregion
1888
1888
}
1889
1889
}