← 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 }