├── README.md ├── addon.json ├── image.jpeg └── lua ├── autorun ├── client │ └── cl_downloader_init.lua ├── server │ └── sv_downloader_init.lua └── sh_downloader_init.lua └── downloader ├── cache_load.lua ├── cache_save.lua ├── gamemode_scanner.lua ├── gui_handler.lua ├── legacy_scanner.lua ├── manual_loader.lua ├── map_scanner.lua ├── mounted_loader.lua ├── mounted_scanner.lua ├── pointshop1_scanner.lua ├── resource_scanner.lua ├── resources.lua └── telemetry.lua /README.md: -------------------------------------------------------------------------------- 1 | # gmod-workshop-downloader 2 | 3 | When playing on a server, this script automatically makes others download your items from the workshop if they have one of these file types included: mdl, vtf, wav, mp3, ogg, vmt, vtf or png (translating: models, sounds or materials). 4 | 5 | This will make everyone see the contents correctly (as long as they aren't buggy, of course), so many ERRORs (missing models) and black and purple textures (missing textures) will be "fixed" without the need of manually subscribing to millions of contents! 6 | 7 | Besides that, if you have Pointshop this addon also display any unused models. 8 | 9 | In short: 10 | It's the best solution for your server, only the right workshop files are selected; 11 | Play with your friends easily, don't ask them to subscribe to your addons; 12 | Don't spend time making a script manually, make it really automatic as it should be! 13 | 14 | Available at workshop: https://steamcommunity.com/sharedfiles/filedetails/?id=2214712098 15 | -------------------------------------------------------------------------------- /addon.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Workshop Downloader", 3 | "type": "tool", 4 | "tags": [], 5 | "ignore": [ 6 | "README.md", 7 | ".git/*", 8 | "image.jpeg" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ceifa/gmod-workshop-downloader/9a4507bf8c3bd8a3188f7a3b43623829bbedbe6b/image.jpeg -------------------------------------------------------------------------------- /lua/autorun/client/cl_downloader_init.lua: -------------------------------------------------------------------------------- 1 | if game.SinglePlayer() or not GetConVar("downloader_gui_enabled"):GetBool() then 2 | return 3 | end 4 | 5 | surface.CreateFont("uwd_panel_font", { 6 | font = "Arial", 7 | extended = false, 8 | size = 12, 9 | weight = 500, 10 | blursize = 0, 11 | scanlines = 0, 12 | antialias = true, 13 | underline = false, 14 | italic = false, 15 | strikeout = false, 16 | symbol = false, 17 | rotary = false, 18 | shadow = true, 19 | additive = false, 20 | outline = false, 21 | }) 22 | 23 | local FILTER = { 24 | ALL = 0, 25 | SELECTED = 1, 26 | IGNORED = 2, 27 | MANUAL = 3 28 | } 29 | 30 | local Browser = {} 31 | 32 | Browser.initialized = false 33 | 34 | Browser.cacheFile = "uwd/browser_cache.txt" 35 | 36 | Browser.topBar = 25 37 | Browser.border = 10 38 | Browser.backgroundColor = Color(255, 255, 255, 255) 39 | 40 | Browser.titleBarBackgroundColor = Color(37, 37, 37, 255) 41 | 42 | Browser.iconSize = 150 43 | Browser.iconTextHeight = 25 44 | Browser.controlButtonsWidth = 100 45 | 46 | Browser.commonHeight = 25 47 | Browser.scrollBarWidth = 15 48 | Browser.filterWidth = 125 49 | 50 | Browser.maxIconsWidth = math.floor((ScrW() * 0.8) / Browser.iconSize) 51 | Browser.maxIconsHeight = math.floor((ScrH() * 0.9) / Browser.iconSize) 52 | 53 | Browser.width = Browser.maxIconsWidth * Browser.iconSize + Browser.maxIconsWidth * Browser.border + Browser.border + Browser.scrollBarWidth 54 | Browser.height = Browser.maxIconsHeight * Browser.iconSize + Browser.maxIconsHeight * Browser.border + Browser.border + Browser.topBar 55 | 56 | Browser.iconBackgroundColor = { 57 | ignored = Color(246, 255, 228), 58 | selected = Color(188, 235, 96), 59 | restart = Color(255, 130, 130), 60 | rejoin = Color(255, 255, 71) 61 | } 62 | 63 | function Browser:LoadCache() 64 | local cache = file.Read(self.cacheFile, "DATA") 65 | self.cache = util.JSONToTable(cache or "{}") 66 | end 67 | 68 | function Browser:SaveCache() 69 | file.Write(self.cacheFile, util.TableToJSON(self.cache)) 70 | end 71 | 72 | function Browser:Open(scanResult) 73 | if scanResult then 74 | self.scanResult = scanResult 75 | end 76 | 77 | if self.initialized then 78 | self.frame:SetVisible(true) 79 | else 80 | self:SetupBasePanels() 81 | self:LoadCache() 82 | self:PopulateList() 83 | self.initialized = true 84 | end 85 | end 86 | 87 | function Browser:SetupBasePanels() 88 | if self.initialized then return end 89 | 90 | self.frame = vgui.Create("DFrame") 91 | self.frame:SetTitle("Ultimate Workshop Downloader") 92 | self.frame:SetSize(self.width, self.height) 93 | self.frame:SetDeleteOnClose(false) 94 | self.frame:SetIcon("games/16/all.png") 95 | self.frame:SetVisible(true) 96 | self.frame:Center() 97 | self.frame:MakePopup() 98 | function Browser:Close() 99 | self:SetVisible(false) 100 | end 101 | 102 | -- Element properties 103 | 104 | local panelInfo = { 105 | width = self.frame:GetWide(), 106 | height = self.frame:GetTall() - self.topBar, 107 | x = 0, 108 | y = self.topBar 109 | } 110 | 111 | local panelTitleInfo = { 112 | width = self.frame:GetWide(), 113 | height = self.topBar, 114 | x = 0, 115 | y = 0 116 | } 117 | 118 | local filterInfo = { 119 | width = self.filterWidth, 120 | height = self.commonHeight, 121 | x = self.frame:GetWide() - self.filterWidth - self.controlButtonsWidth, 122 | y = 0 123 | } 124 | 125 | local addonsScrollInfo = { 126 | width = self.frame:GetWide() - self.border, 127 | height = self.frame:GetTall() - self.topBar - self.border * 2, 128 | x = self.border, 129 | y = self.border 130 | } 131 | 132 | -- Main area 133 | self.panel = vgui.Create("DPanel", self.frame) 134 | self.panel:SetSize(panelInfo.width, panelInfo.height) 135 | self.panel:SetPos(panelInfo.x, panelInfo.y) 136 | self.panel:SetBackgroundColor(Browser.backgroundColor) 137 | 138 | -- Alert text 139 | self.alertTextBackground = vgui.Create("DPanel", self.frame) 140 | self.alertTextBackground:SetPos(0, self.iconSize - self.iconTextHeight) 141 | self.alertTextBackground:SetBackgroundColor(iconBackgroundColor) 142 | self.alertTextBackground:SetVisible(false) 143 | 144 | self.alertText = vgui.Create("DLabel", self.alertTextBackground) 145 | self.alertText:SetPos(5, 5) 146 | self.alertText:SetColor(Color(0, 0, 0, 255)) 147 | 148 | -- Icons list 149 | self.addonsScroll = vgui.Create("DScrollPanel", self.panel) 150 | self.addonsScroll:SetSize(addonsScrollInfo.width, addonsScrollInfo.height) 151 | self.addonsScroll:SetPos(addonsScrollInfo.x, addonsScrollInfo.y) 152 | 153 | self.addonsList = vgui.Create("DIconLayout", self.addonsScroll) 154 | self.addonsList:Dock(FILL) 155 | self.addonsList:SetSpaceY(self.border) 156 | self.addonsList:SetSpaceX(self.border) 157 | self.addonsList:IsHovered() 158 | self.addonsList.void = {} -- Used to hide elements when filtering 159 | 160 | -- Filter 161 | self.filter = vgui.Create("DComboBox", self.frame) 162 | self.filter:SetSize(filterInfo.width, filterInfo.height) 163 | self.filter:SetPos(filterInfo.x, filterInfo.y) 164 | self.filter:AddChoice("All", FILTER.ALL, false, "icon16/box.png") 165 | self.filter:AddChoice("Selected", FILTER.SELECTED, false, "icon16/accept.png") 166 | self.filter:AddChoice("Ignored", FILTER.IGNORED, false, "icon16/delete.png") 167 | self.filter:AddChoice("Manual", FILTER.MANUAL, false, "icon16/pencil.png") 168 | self.filter:SetText("All") 169 | self.filter.OnSelect = function(self, index, value) 170 | Browser:FilterAddons(self:GetOptionData(index)) 171 | end 172 | end 173 | 174 | -- An alert message saying what's needed to fully apply the changes 175 | function Browser:SetAlertText() 176 | -- Check if the message is needed and show or hide the panel 177 | local found 178 | for wsid, resultInfo in pairs(self.scanResult) do 179 | if resultInfo.manual ~= nil then 180 | if resultInfo.manual == true then 181 | if not self.scanResult[wsid].selected then 182 | found = "Select" 183 | end 184 | else 185 | found = "Ignore" 186 | break 187 | end 188 | end 189 | end 190 | 191 | if not found then 192 | if self.alertTextBackground:IsVisible() then 193 | self.alertTextBackground:SetVisible(false) 194 | end 195 | 196 | return 197 | end 198 | 199 | if not self.alertTextBackground:IsVisible() then 200 | self.alertTextBackground:SetVisible(true) 201 | end 202 | 203 | -- Set the correct message / background color and resize the element 204 | if found == "Select" then 205 | self.alertText:SetText("You need to rejoin the server to download the new selected addons.") 206 | self.alertTextBackground:SetBackgroundColor(self.iconBackgroundColor.rejoin) 207 | elseif found == "Ignore" then 208 | self.alertText:SetText("The server needs to be restarted to remove new ignored or previously selected addons.") 209 | self.alertTextBackground:SetBackgroundColor(self.iconBackgroundColor.restart) 210 | end 211 | self.alertText:SizeToContents() 212 | 213 | self.alertTextBackground:SizeToChildren(true, true) 214 | local alertBackW = self.alertTextBackground:GetWide() + 5 215 | 216 | local alertTextInfo = { 217 | width = alertBackW, 218 | height = self.commonHeight, 219 | x = self.frame:GetWide()/2 - alertBackW/2, 220 | y = 0 221 | } 222 | 223 | self.alertTextBackground:SetSize(alertTextInfo.width, alertTextInfo.height) 224 | self.alertTextBackground:SetPos(alertTextInfo.x, alertTextInfo.y) 225 | end 226 | 227 | -- Fill up the menu with the addons, always sorted by name 228 | function Browser:PopulateList() 229 | local addons = engine.GetAddons() -- I get the addon names locally instead of sending them through net 230 | local baseDelayNotFound = 0.05 231 | local baseDelayFound = 0.025 232 | local delay = 0.45 233 | 234 | table.sort(addons, function(a, b) return string.lower(a.title) < string.lower(b.title) end) 235 | 236 | for k, addonInfo in ipairs(addons) do 237 | local wsid = tonumber(addonInfo.wsid) -- Note: loading the cache from a json always converts the wsid to number 238 | 239 | if not self.scanResult[wsid] then continue end -- Ignore unmounted addons 240 | 241 | local iconBackgroundColor 242 | if self.scanResult[wsid].cachedManual == nil and self.scanResult[wsid].selected or self.scanResult[wsid].cachedManual then 243 | iconBackgroundColor = self.iconBackgroundColor.selected 244 | else 245 | iconBackgroundColor = self.iconBackgroundColor.ignored 246 | end 247 | 248 | local iconArea = self.addonsList:Add(vgui.Create("DPanel")) 249 | iconArea:SetSize(self.iconSize, self.iconSize) 250 | iconArea:SetBackgroundColor(iconBackgroundColor) 251 | 252 | self.scanResult[wsid].iconArea = iconArea 253 | 254 | local contentSize = self.iconSize * 0.94 255 | local contentBorder = self.iconSize * 0.06 / 2 256 | 257 | local iconBackground = vgui.Create("DPanel", iconArea) 258 | iconBackground:SetPos(contentBorder, contentBorder) 259 | iconBackground:SetSize(contentSize, contentSize) 260 | 261 | local iconTitleBackground = vgui.Create("DPanel", iconArea) 262 | iconTitleBackground:SetSize(self.iconSize, self.iconTextHeight) 263 | iconTitleBackground:SetBackgroundColor(iconBackgroundColor) 264 | iconTitleBackground:SetTooltip(addonInfo.title) 265 | 266 | local iconTitle = vgui.Create("DLabel", iconTitleBackground) 267 | iconTitle:SetWide(self.iconSize) 268 | iconTitle:SetPos(5, 2) 269 | iconTitle:SetText(addonInfo.title) 270 | iconTitle:SetColor(Color(0, 0, 0, 255)) 271 | 272 | local manualAlertBackground = vgui.Create("DPanel", iconArea) 273 | manualAlertBackground:SetPos(0, self.iconSize - self.iconTextHeight * 2 - self.border) 274 | manualAlertBackground:SetBackgroundColor(iconBackgroundColor) 275 | 276 | local manualAlert = vgui.Create("DLabel", manualAlertBackground) 277 | manualAlert:SetPos(5, 5) 278 | manualAlert:SetText("Manual") 279 | manualAlert:SetColor(Color(0, 0, 0, 255)) 280 | 281 | manualAlertBackground:Hide() 282 | if self.scanResult[wsid].cachedManual ~= nil and self.scanResult[wsid].cachedManual ~= self.scanResult[wsid].selected then 283 | manualAlertBackground:Show() 284 | end 285 | 286 | manualAlert:SizeToContents() 287 | 288 | manualAlertBackground:SizeToChildren(true, true) 289 | local manualBackW = manualAlertBackground:GetWide() 290 | manualAlertBackground:SetSize(manualBackW + 5, self.commonHeight) 291 | 292 | local iconTypeBackground = vgui.Create("DPanel", iconArea) 293 | iconTypeBackground:SetPos(0, self.iconSize - self.iconTextHeight) 294 | iconTypeBackground:SetBackgroundColor(iconBackgroundColor) 295 | 296 | local iconType = vgui.Create("DLabel", iconTypeBackground) 297 | iconType:SetPos(5, 5) 298 | iconType:SetText(self.scanResult[wsid].type) 299 | iconType:SetColor(Color(0, 0, 0, 255)) 300 | iconType:SizeToContents() 301 | 302 | iconTypeBackground:SizeToChildren(true, true) 303 | local typeBackW = iconTypeBackground:GetWide() 304 | iconTypeBackground:SetSize(typeBackW + 5, self.commonHeight) 305 | 306 | local icon = vgui.Create("DImageButton", iconBackground) 307 | icon:SetSize(contentSize, contentSize) 308 | icon:SetPos(0, 0) 309 | icon:SetTooltip("Left Click - Toggle | Right Click - Open on Workshop") 310 | icon.OnDepressed = function() 311 | -- Workshop page 312 | if input.IsMouseDown(108) then -- MOUSE_RIGHT 313 | steamworks.ViewFile(wsid) 314 | -- Toggle selection 315 | elseif input.IsMouseDown(107) then -- MOUSE_LEFT 316 | if not LocalPlayer():IsAdmin() then return end 317 | 318 | -- Get the initial state 319 | local initialState 320 | if self.scanResult[wsid].cachedManual ~= nil then 321 | initialState = self.scanResult[wsid].cachedManual 322 | else 323 | initialState = self.scanResult[wsid].selected 324 | end 325 | 326 | -- Initialize the new manual state 327 | if self.scanResult[wsid].manual == nil then 328 | self.scanResult[wsid].manual = not initialState 329 | else 330 | self.scanResult[wsid].manual = not self.scanResult[wsid].manual 331 | end 332 | 333 | -- Change the Manual panel message and show or hide it 334 | if self.scanResult[wsid].cachedManual ~= nil and 335 | self.scanResult[wsid].cachedManual ~= self.scanResult[wsid].selected 336 | then 337 | if self.scanResult[wsid].manual == initialState then 338 | manualAlertBackground:Show() 339 | else 340 | manualAlertBackground:Hide() 341 | end 342 | else 343 | if self.scanResult[wsid].manual == initialState then 344 | manualAlertBackground:Hide() 345 | else 346 | manualAlertBackground:Show() 347 | end 348 | end 349 | 350 | iconType:SizeToContents() 351 | 352 | iconTypeBackground:SizeToChildren(true, true) 353 | local backW = iconTypeBackground:GetWide() 354 | iconTypeBackground:SetSize(backW + 5, backW + 2) 355 | 356 | -- Change general background colors 357 | if self.scanResult[wsid].manual == true then 358 | local backgroundColor 359 | 360 | if initialState then 361 | backgroundColor = self.iconBackgroundColor.selected 362 | else 363 | backgroundColor = self.iconBackgroundColor.rejoin 364 | end 365 | 366 | iconArea:SetBackgroundColor(backgroundColor) 367 | iconTitleBackground:SetBackgroundColor(backgroundColor) 368 | iconTypeBackground:SetBackgroundColor(backgroundColor) 369 | manualAlertBackground:SetBackgroundColor(backgroundColor) 370 | else 371 | iconArea:SetBackgroundColor(self.iconBackgroundColor.restart) 372 | iconTitleBackground:SetBackgroundColor(self.iconBackgroundColor.restart) 373 | iconTypeBackground:SetBackgroundColor(self.iconBackgroundColor.restart) 374 | manualAlertBackground:SetBackgroundColor(self.iconBackgroundColor.restart) 375 | end 376 | 377 | -- Add the alert message to the top of the page saying what's needed to fully apply the changes 378 | self:SetAlertText() 379 | 380 | -- Set the new value on the server 381 | net.Start("uwd_set_manual_selection") 382 | net.WriteString(tostring(wsid)) 383 | net.WriteBool(self.scanResult[wsid].manual) 384 | net.SendToServer() 385 | end 386 | end 387 | 388 | -- The images will be retrieved from the workshop or from the cache 389 | -- while the delay make sure we don't send too many requests neither 390 | -- read too much info from the disk at the same time. I save the cache 391 | -- every 20 new downloads and when the process is done (given at least 392 | -- 1 new image was downloaded) 393 | local iteratingNewAddons = 0 394 | if self.cache[wsid] and file.Exists(self.cache[wsid], "GAME") then 395 | delay = delay + baseDelayFound 396 | timer.Simple(delay, function() 397 | if IsValid(self.frame) and IsValid(icon) then 398 | local iconMaterial = AddonMaterial(self.cache[wsid]) 399 | icon:SetMaterial(iconMaterial) 400 | end 401 | end) 402 | else 403 | delay = delay + baseDelayNotFound 404 | iteratingNewAddons = iteratingNewAddons + 1 405 | timer.Simple(delay, function() 406 | steamworks.FileInfo(addonInfo.wsid, function(result) 407 | steamworks.Download(result.previewid, true, function(cachePath) 408 | if IsValid(self.frame) and IsValid(icon) then 409 | local iconMaterial = AddonMaterial(cachePath) 410 | icon:SetMaterial(iconMaterial) 411 | self.cache[wsid] = cachePath 412 | end 413 | end) 414 | end) 415 | end) 416 | end 417 | 418 | if iteratingNewAddons == 20 or (k == #addons and iteratingNewAddons != 0) then 419 | iteratingNewAddons = 0 420 | timer.Simple(delay + 1, function() 421 | if IsValid(self.frame) then 422 | self:SaveCache() 423 | end 424 | end) 425 | end 426 | end 427 | end 428 | 429 | -- Filter the addons by type, always sorted by name 430 | function Browser:FilterAddons(selectedValue) 431 | -- Getting addons 432 | local addons = engine.GetAddons() 433 | 434 | table.sort(addons, function(a, b) return string.lower(a.title) < string.lower(b.title) end) 435 | 436 | -- Move all icons to the void and hide them 437 | for k, addonInfo in ipairs(addons) do 438 | local wsid = tonumber(addonInfo.wsid) 439 | local scanInfo = self.scanResult[wsid] 440 | 441 | if not scanInfo then continue end 442 | 443 | if IsValid(scanInfo.iconArea) then 444 | scanInfo.iconArea:SetParent(self.addonsList.void) 445 | scanInfo.iconArea:Hide() 446 | end 447 | end 448 | 449 | -- Move the selected icons back to the panel and make sure they appear 450 | for k, addonInfo in ipairs(addons) do 451 | local wsid = tonumber(addonInfo.wsid) 452 | local scanInfo = self.scanResult[wsid] 453 | 454 | if not scanInfo then continue end 455 | 456 | if IsValid(scanInfo.iconArea) then 457 | if selectedValue == FILTER.ALL then 458 | scanInfo.iconArea:Show() 459 | scanInfo.iconArea:SetParent(self.addonsList) 460 | elseif selectedValue == FILTER.SELECTED then 461 | if scanInfo.manual == nil and scanInfo.cachedManual == nil and scanInfo.selected == true or 462 | scanInfo.manual == nil and scanInfo.cachedManual == true or 463 | scanInfo.manual == true 464 | then 465 | scanInfo.iconArea:Show() 466 | scanInfo.iconArea:SetParent(self.addonsList) 467 | end 468 | elseif selectedValue == FILTER.IGNORED then 469 | if scanInfo.manual == nil and scanInfo.cachedManual == nil and scanInfo.selected == false or 470 | scanInfo.manual == nil and scanInfo.cachedManual == false or 471 | scanInfo.manual == false 472 | then 473 | scanInfo.iconArea:Show() 474 | scanInfo.iconArea:SetParent(self.addonsList) 475 | end 476 | elseif selectedValue == FILTER.MANUAL then 477 | if scanInfo.manual == nil and scanInfo.cachedManual ~= nil and scanInfo.cachedManual ~= scanInfo.selected or 478 | scanInfo.manual ~= nil and scanInfo.manual ~= scanInfo.selected 479 | then 480 | scanInfo.iconArea:Show() 481 | scanInfo.iconArea:SetParent(self.addonsList) 482 | end 483 | end 484 | end 485 | end 486 | 487 | -- Refresh the scroll bar 488 | timer.Simple(0.2, function() 489 | if IsValid(self.frame) and IsValid(self.addonsScroll) then 490 | self.addonsScroll:InvalidateLayout() 491 | end 492 | end) 493 | end 494 | 495 | do 496 | local receivedTab = {} 497 | net.Receive("uwd_exchange_scan_result", function(len, ply) 498 | local chunksID = net.ReadString() 499 | local chunksSubID = net.ReadUInt(32) 500 | local len = net.ReadUInt(16) 501 | local chunk = net.ReadData(len) 502 | local isLastChunk = net.ReadBool() 503 | 504 | -- Initialize streams or reset overwriten ones 505 | if not receivedTab[chunksID] or receivedTab[chunksID].chunksSubID ~= chunksSubID then 506 | receivedTab[chunksID] = { 507 | chunksSubID = chunksSubID, 508 | data = "" 509 | } 510 | 511 | -- 3 minutes to remove possible memory leaks 512 | timer.Create(chunksID, 180, 1, function() 513 | receivedTab[chunksID] = nil 514 | end) 515 | end 516 | 517 | -- Rebuild the compressed string 518 | receivedTab[chunksID].data = receivedTab[chunksID].data .. chunk 519 | 520 | -- Finish stream 521 | if isLastChunk then 522 | local data = receivedTab[chunksID].data 523 | 524 | Browser:Open(util.JSONToTable(util.Decompress(data))) 525 | end 526 | end) 527 | end 528 | 529 | concommand.Add("downloader_page", function() 530 | steamworks.ViewFile(2214712098) 531 | end) 532 | 533 | concommand.Add("downloader_menu", function() 534 | if Browser.scanResult then 535 | Browser:Open() 536 | else 537 | net.Start("uwd_exchange_scan_result") 538 | net.SendToServer() 539 | end 540 | end) 541 | 542 | 543 | 544 | 545 | local function CPanel(self) 546 | self:Help("UWD - Install and forget.") 547 | self:Button("Open Menu", "downloader_menu") 548 | self:ControlHelp("- Automatic") 549 | self:ControlHelp("- Intelligent addon selection") 550 | self:ControlHelp("- Extremely fast") 551 | self:ControlHelp("- Secure") 552 | self:ControlHelp("- For listen and dedicated servers") 553 | self:ControlHelp("- Supports legacy addons") 554 | self:ControlHelp("- Has an easy yet powerful panel") 555 | self:ControlHelp("- Validates pointshop models") 556 | self:ControlHelp("- Actually works") 557 | self:Help("If you find any addons that were not detected, please report them to us!") 558 | self:Button("Report Error", "downloader_page") 559 | end 560 | 561 | hook.Add("PopulateToolMenu", "All hail the menus", function () 562 | spawnmenu.AddToolMenuOption("Utilities", "Ultimate Workshop Downloder", "Addons", "Addons", "", "", CPanel) 563 | end) 564 | 565 | -- Tests 566 | 567 | -- if UWD_Test and IsValid(UWD_Test.frame) then 568 | -- UWD_Test.frame:Remove() 569 | -- end 570 | 571 | -- UWD_Test = Browser 572 | 573 | -- if Browser.scanResult then 574 | -- Browser:Open() 575 | -- else 576 | -- net.Start("uwd_exchange_scan_result") 577 | -- net.SendToServer() 578 | -- end 579 | -------------------------------------------------------------------------------- /lua/autorun/server/sv_downloader_init.lua: -------------------------------------------------------------------------------- 1 | if game.SinglePlayer() then 2 | return 3 | end 4 | 5 | local files = file.Find("downloader/*.lua", "LUA") 6 | local modules = {} 7 | 8 | for _, moduleFile in ipairs(files) do 9 | table.insert(modules, include("downloader/" .. moduleFile)) 10 | end 11 | 12 | table.sort(modules, function(a, b) 13 | if a.Order == nil or b.Order == nil then 14 | return a.Order ~= nil 15 | end 16 | 17 | return a.Order < b.Order 18 | end) 19 | 20 | local context = { 21 | dataFolder = "uwd", 22 | addons = engine.GetAddons(), 23 | ignoreResources = {}, 24 | usingAddons = {}, 25 | started = SysTime(), 26 | gamemodeAddons = {}, 27 | addonsToCache = {}, 28 | manualAddons = {}, 29 | scanResult = {} 30 | } 31 | 32 | if not file.Exists(context.dataFolder, "DATA") then 33 | file.CreateDir(context.dataFolder) 34 | end 35 | 36 | for _, downloaderModule in ipairs(modules) do 37 | if downloaderModule.Run then 38 | downloaderModule:Run(context) 39 | end 40 | end 41 | 42 | -- Garbage collection will clean the context and other local vars by itself after execution -------------------------------------------------------------------------------- /lua/autorun/sh_downloader_init.lua: -------------------------------------------------------------------------------- 1 | CreateConVar("downloader_gui_enabled", 1, FCVAR_ARCHIVE + FCVAR_REPLICATED, 2 | "Should enable GUI for users (require restart)") -------------------------------------------------------------------------------- /lua/downloader/cache_load.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 2 3 | 4 | function MODULE:Run(context) 5 | local cacheFile = context.dataFolder .. "/workshop_cache.txt" 6 | local cache = util.JSONToTable(file.Read(cacheFile, "DATA") or "{}") or {} 7 | 8 | for _, addon in ipairs(context.addons) do 9 | -- tonumber because of https://github.com/Facepunch/garrysmod-issues/issues/3561#issuecomment-428479149 10 | local scanned = cache[tonumber(addon.wsid)] 11 | 12 | -- cache exists and is up to date? 13 | if scanned and scanned.updated == addon.updated then 14 | if scanned.hasResource then 15 | table.insert(context.usingAddons, addon) 16 | context.addonsToCache[addon.wsid] = true 17 | context.scanResult[addon.wsid] = { selected = true, type = "Resources" } 18 | else 19 | context.scanResult[addon.wsid] = { selected = false, type = "Lua" } 20 | end 21 | 22 | context.ignoreResources[addon.wsid] = true 23 | context.gamemodeAddons[addon.wsid] = scanned.isGamemode 24 | context.manualAddons[addon.wsid] = scanned.manual 25 | end 26 | end 27 | 28 | context.cacheQuantity = table.Count(cache) 29 | end 30 | 31 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/cache_save.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 8 3 | 4 | local shouldDumpWorkshopCache = CreateConVar("downloader_dump_workshop_cache", 0, FCVAR_ARCHIVE, "Should dump the next Workshop resources scan into a txt file") 5 | 6 | local function DumpWorkshopCache(context) 7 | local cacheFile = context.dataFolder .. "/dump_workshop_cache.txt" 8 | local cache = "if SERVER then\n" 9 | 10 | for _, addon in ipairs(context.usingAddons) do 11 | cache = cache .. " resource.AddWorkshop(\"" .. addon.wsid .. "\") -- " .. addon.title .. "\n" 12 | end 13 | 14 | cache = cache .. "end\n" 15 | 16 | shouldDumpWorkshopCache:SetBool(false) 17 | 18 | file.Write(cacheFile, cache) 19 | 20 | print("[DOWNLOADER] RESOURCES DUMPED INTO '" .. cacheFile .. "'") 21 | end 22 | 23 | function MODULE:Run(context) 24 | local cacheFile = context.dataFolder .. "/workshop_cache.txt" 25 | local cache = {} 26 | 27 | for _, addon in ipairs(context.addons) do 28 | cache[addon.wsid] = { 29 | hasResource = context.addonsToCache[addon.wsid] == true, 30 | updated = addon.updated, 31 | isGamemode = context.gamemodeAddons[addon.wsid] == true, 32 | manual = context.manualAddons[addon.wsid] 33 | } 34 | end 35 | 36 | file.Write(cacheFile, util.TableToJSON(cache)) 37 | 38 | if shouldDumpWorkshopCache:GetBool() and #context.usingAddons > 0 then 39 | DumpWorkshopCache(context) 40 | end 41 | end 42 | 43 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/gamemode_scanner.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 3 3 | 4 | local currentGamemode = engine.ActiveGamemode() 5 | 6 | local function IsCurrentGamemode(gamemodeFolders, addonTitle) 7 | local totalGamemodeFiles = 0 8 | 9 | for _, gamemodeFolder in ipairs(gamemodeFolders) do 10 | if gamemodeFolder == currentGamemode then 11 | return true 12 | end 13 | 14 | local gamemodeFiles = file.Find("gamemodes/" .. gamemodeFolder .. "/*.txt", addonTitle) 15 | 16 | totalGamemodeFiles = totalGamemodeFiles + #gamemodeFiles 17 | 18 | for _, gamemodeFile in ipairs(gamemodeFiles) do 19 | if gamemodeFile == currentGamemode .. ".txt" then 20 | return true 21 | end 22 | end 23 | end 24 | 25 | if totalGamemodeFiles > 0 then 26 | return false 27 | end 28 | end 29 | 30 | function MODULE:Run(context) 31 | for _, addon in ipairs(context.addons) do 32 | if context.gamemodeAddons[addon.wsid] ~= false then 33 | -- Does not support wildcard on folders :( 34 | local _, gamemodeFolders = file.Find("gamemodes/*", addon.title) 35 | 36 | if #gamemodeFolders > 0 then 37 | context.gamemodeAddons[addon.wsid] = true 38 | 39 | -- true if it's current gamemode 40 | -- false if it's a gamemode but not the current 41 | -- nil if it's a false positive, probably not a gamemode 42 | local isCurrentGamemode = IsCurrentGamemode(gamemodeFolders, addon.title) 43 | if isCurrentGamemode ~= nil then 44 | if isCurrentGamemode then 45 | table.insert(context.usingAddons, addon) 46 | context.scanResult[addon.wsid] = { selected = true, type = "Current gamemode" } 47 | else 48 | context.scanResult[addon.wsid] = { selected = false, type = "Unused gamemode" } 49 | end 50 | 51 | -- Is probably a gamemode addon, resources should be ignored 52 | context.ignoreResources[addon.wsid] = true 53 | end 54 | end 55 | end 56 | end 57 | end 58 | 59 | return MODULE 60 | -------------------------------------------------------------------------------- /lua/downloader/gui_handler.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 9 3 | 4 | if GetConVar("downloader_gui_enabled"):GetBool() then 5 | util.AddNetworkString("uwd_exchange_scan_result") 6 | util.AddNetworkString("uwd_set_manual_selection") 7 | 8 | local subContext = {} 9 | 10 | -- Send huge binary 11 | local sendTab = {} 12 | local function SendData(chunksID, data, toPly) 13 | local chunksSubID = SysTime() 14 | 15 | local totalSize = string.len(data) 16 | local chunkSize = 64000 -- ~64KB max 17 | local totalChunks = math.ceil(totalSize / chunkSize) 18 | 19 | -- 3 minutes to remove possible memory leaks 20 | sendTab[chunksID] = chunksSubID 21 | timer.Create(chunksID, 180, 1, function() 22 | sendTab[chunksID] = nil 23 | end) 24 | 25 | for i = 1, totalChunks, 1 do 26 | local startByte = chunkSize * (i - 1) + 1 27 | local remaining = totalSize - (startByte - 1) 28 | local endByte = remaining < chunkSize and (startByte - 1) + remaining or chunkSize * i 29 | local chunk = string.sub(data, startByte, endByte) 30 | 31 | timer.Simple(i * 0.1, function() 32 | if sendTab[chunksID] ~= chunksSubID then return end 33 | 34 | local isLastChunk = i == totalChunks 35 | 36 | net.Start("uwd_exchange_scan_result") 37 | net.WriteString(chunksID) 38 | net.WriteUInt(sendTab[chunksID], 32) 39 | net.WriteUInt(#chunk, 16) 40 | net.WriteData(chunk, #chunk) 41 | net.WriteBool(isLastChunk) 42 | if SERVER then 43 | if toPly then 44 | net.Send(toPly) 45 | else 46 | net.Broadcast() 47 | end 48 | else 49 | net.SendToServer() 50 | end 51 | 52 | if isLastChunk then 53 | sendTab[chunksID] = nil 54 | end 55 | end) 56 | end 57 | end 58 | 59 | net.Receive("uwd_exchange_scan_result", function(len, ply) 60 | local scanResult = table.Copy(subContext.scanResult) 61 | 62 | for wsid, value in pairs(subContext.manualAddons) do 63 | if scanResult[wsid] then 64 | scanResult[wsid].cachedManual = value 65 | end 66 | end 67 | 68 | scanResult = util.Compress(util.TableToJSON(scanResult)) 69 | 70 | SendData(tostring(ply), scanResult, ply) 71 | end) 72 | 73 | net.Receive("uwd_set_manual_selection", function(len, ply) 74 | if not ply:IsAdmin() then return end 75 | 76 | local wsid = tonumber(net.ReadString()) 77 | local value = net.ReadBool() 78 | 79 | local cacheFile = subContext.dataFolder .. "/workshop_cache.txt" 80 | local cache = util.JSONToTable(file.Read(cacheFile, "DATA") or "{}") or {} 81 | 82 | cache[wsid] = cache[wsid] or {} 83 | cache[wsid].manual = value 84 | 85 | subContext.manualAddons[wsid] = value 86 | 87 | file.Write(cacheFile, util.TableToJSON(cache)) 88 | end) 89 | 90 | function MODULE:Run(context) 91 | subContext.scanResult = table.Copy(context.scanResult) 92 | subContext.manualAddons = table.Copy(context.manualAddons) 93 | subContext.dataFolder = context.dataFolder 94 | end 95 | end 96 | 97 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/legacy_scanner.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 0 3 | 4 | local resourceExtensions = include("downloader/resources.lua") 5 | 6 | local shouldScan = CreateConVar("downloader_legacy_scan_danger", 0, FCVAR_ARCHIVE, 7 | "Should scan for legacy addons (DANGER!!!)") 8 | local shouldDumpLegacyCache = CreateConVar("downloader_dump_legacy_cache", 0, FCVAR_ARCHIVE, 9 | "Should dump the next Legacy Addon resources scan into a txt file") 10 | 11 | local function DumpLegacyCache(legacyFilesPerAddon) 12 | local cacheFile = "uwd/dump_legacy_cache.txt" 13 | local cache = "if SERVER then\n" 14 | 15 | for addonName, files in SortedPairs(legacyFilesPerAddon) do 16 | cache = cache .. " -- " .. addonName .. "\n" 17 | for _, legacyFile in SortedPairs(files) do 18 | cache = cache .. " resource.AddSingleFile(\"" .. legacyFile .. "\")\n" 19 | end 20 | end 21 | 22 | cache = cache .. "end\n" 23 | 24 | shouldDumpLegacyCache:SetBool(false) 25 | 26 | file.Write(cacheFile, cache) 27 | 28 | print("[DOWNLOADER] LEGACY ADDON RESOURCES DUMPED INTO '" .. cacheFile .. "'") 29 | end 30 | 31 | local function AddFiles(originPath, currentPath, legacyFiles) 32 | local files, dirs = file.Find(originPath .. currentPath .. "*", "MOD") 33 | 34 | if files then 35 | for _, subFile in ipairs(files) do 36 | local ext = string.GetExtensionFromFilename(subFile) 37 | if resourceExtensions[ext] then 38 | table.insert(legacyFiles, currentPath .. subFile) 39 | end 40 | end 41 | end 42 | 43 | if dirs then 44 | for _, subDir in ipairs(dirs) do 45 | AddFiles(originPath, currentPath .. subDir .. "/", legacyFiles) 46 | end 47 | end 48 | end 49 | 50 | local function ScanAddons(context) 51 | local _, folders = file.Find("addons/*", "MOD") 52 | local currentMap = game.GetMap() 53 | local legacyFilesPerAddon = {} 54 | 55 | print("[DOWNLOADER] SCANNING " .. #folders .. " LEGACY ADDONS TO ADD RESOURCES...") 56 | 57 | for _, folder in ipairs(folders or {}) do 58 | legacyFilesPerAddon[folder] = {} 59 | 60 | AddFiles("addons/" .. folder .. "/", "", legacyFilesPerAddon[folder]) 61 | 62 | local mapFiles = file.Find("addons/" .. folder .. "/maps/*.bsp", "MOD") or {} 63 | for _, mapFile in ipairs(mapFiles) do 64 | if string.StripExtension(mapFile) == currentMap then 65 | table.insert(legacyFilesPerAddon[folder], "maps/" .. mapFile) 66 | end 67 | end 68 | end 69 | 70 | local isFastDL = GetConVar("sv_downloadurl"):GetString() ~= "" 71 | local isServerDL = not isFastDL 72 | local downloadSize = 0 73 | local fileQuantity = 0 74 | 75 | local netMaxFileSize = GetConVar("net_maxfilesize"):GetInt() 76 | 77 | for addonName, legacyFiles in pairs(legacyFilesPerAddon) do 78 | print(string.format("[DOWNLOADER] [+] LEGACY %4d FILES ADDED FOR %s", #legacyFiles, addonName)) 79 | 80 | for _, legacyFile in ipairs(legacyFiles) do 81 | resource.AddSingleFile(legacyFile) 82 | 83 | local extension = isFastDL and file.Exists(legacyFile .. ".bz2", "GAME") and ".bz2" or "" 84 | local fileSize = math.Round(file.Size(legacyFile .. extension, "GAME") / 1000000, 2) 85 | if isServerDL and fileSize > netMaxFileSize then 86 | ErrorNoHalt(string.format( 87 | "[DOWNLOADER] [WARNING] LEGACY FILE '%s' IS TOO BIG (%d MB)!! CURRENT LIMIT IS %d MB. EXECUTE 'net_maxfilesize' COMMAND TO INCREASE THE SIZE UP TO 64 MB!\n", 88 | legacyFile, fileSize, netMaxFileSize)) 89 | end 90 | 91 | downloadSize = downloadSize + fileSize 92 | fileQuantity = fileQuantity + 1 93 | end 94 | end 95 | 96 | print(string.format("[DOWNLOADER] FINISHED TO ADD LEGACY ADDONS: %d MB OF %d FILES SELECTED", downloadSize, 97 | fileQuantity)) 98 | 99 | if isFastDL then 100 | print("[DOWNLOADER] USING FASTDL. DOWNLOAD TIME CHANGES ACCORDING TO INTERNET SPEED") 101 | else 102 | -- Note: ServerDL speed is limited to 30KBps, but commonly reaches 20KBps or less, so I'm using 25KBps to approximate time 103 | local time = downloadSize * 1000 / 25 / 60 104 | print(string.format("[DOWNLOADER] USING SERVERDL. APPROXIMATE FULL DOWNLOAD TIME: %.2f MINUTES", time)) 105 | end 106 | 107 | if isServerDL and not GetConVar("sv_allowdownload"):GetBool() then 108 | ErrorNoHalt("[DOWNLOADER] WARNING! YOU ARE TRYING TO USE SERVERDL WITH 'sv_allowdownload' SET TO 0!\n") 109 | end 110 | 111 | if fileQuantity > 8192 then 112 | ErrorNoHalt("[DOWNLOADER] WARNING! YOU ARE TRYING TO DOWNLOAD MORE THAN 8192 FILES WITH SERVERDL!\n") 113 | end 114 | 115 | if context then 116 | context.legacyAddons = table.Count(legacyFilesPerAddon) 117 | context.legacyDownloadSize = downloadSize 118 | end 119 | 120 | if shouldDumpLegacyCache:GetBool() then 121 | DumpLegacyCache(legacyFilesPerAddon) 122 | end 123 | end 124 | 125 | function MODULE:Run(context) 126 | context.legacyScan = shouldScan:GetBool() 127 | if context.legacyScan then 128 | ScanAddons(context) 129 | end 130 | end 131 | 132 | cvars.AddChangeCallback("downloader_legacy_scan_danger", function(convar_name, value_old, value_new) 133 | if value_new == "1" then 134 | ScanAddons() 135 | end 136 | end, "downloader_legacy_scan_danger") 137 | 138 | return MODULE 139 | -------------------------------------------------------------------------------- /lua/downloader/manual_loader.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 6 3 | 4 | function MODULE:Run(context) 5 | local selectedManualAddons = {} 6 | 7 | for k, addon in ipairs(context.addons) do 8 | if context.manualAddons[addon.wsid] then 9 | selectedManualAddons[addon.wsid] = addon 10 | end 11 | end 12 | 13 | for key = #context.usingAddons, 1, -1 do 14 | local addon = context.usingAddons[key] 15 | if context.manualAddons[addon.wsid] == false then 16 | table.remove(context.usingAddons, key) 17 | elseif context.manualAddons[addon.wsid] == true then 18 | selectedManualAddons[addon.wsid] = nil 19 | end 20 | end 21 | 22 | for wsid, addon in pairs(selectedManualAddons) do 23 | table.insert(context.usingAddons, addon) 24 | end 25 | end 26 | 27 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/map_scanner.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 4 3 | 4 | local currentMap = game.GetMap() 5 | 6 | function MODULE:Run(context) 7 | for _, addon in ipairs(context.addons) do 8 | local isMap = addon.tags and addon.tags:lower():find("map") 9 | 10 | if isMap then 11 | if file.Exists("maps/" .. currentMap .. ".bsp", addon.title) then 12 | table.insert(context.usingAddons, addon) 13 | context.scanResult[addon.wsid] = { selected = true, type = "Current map" } 14 | else 15 | context.scanResult[addon.wsid] = { selected = false, type = "Map or map content" } 16 | end 17 | 18 | -- Is probably a map addon, resources should be ignored 19 | context.ignoreResources[addon.wsid] = true 20 | end 21 | end 22 | end 23 | 24 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/mounted_loader.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 7 3 | 4 | function MODULE:Run(context) 5 | for _, usingAddon in ipairs(context.usingAddons) do 6 | resource.AddWorkshop(usingAddon.wsid) 7 | print(string.format("[DOWNLOADER] [+] %-10s %s", usingAddon.wsid, usingAddon.title)) 8 | end 9 | 10 | print("[DOWNLOADER] FINISHED TO ADD RESOURCES: " .. #context.usingAddons .. " ADDONS SELECTED") 11 | end 12 | 13 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/mounted_scanner.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 1 3 | 4 | function MODULE:Run(context) 5 | for key = #context.addons, 1, -1 do 6 | local addon = context.addons[key] 7 | if not addon.downloaded or not addon.mounted then 8 | table.remove(context.addons, key) 9 | end 10 | end 11 | 12 | print("[DOWNLOADER] SCANNING " .. #context.addons .. " ADDONS TO ADD RESOURCES...") 13 | end 14 | 15 | return MODULE 16 | -------------------------------------------------------------------------------- /lua/downloader/pointshop1_scanner.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 10 3 | 4 | if PS then 5 | concommand.Add("downloader_ps1_scan", function(ply) 6 | if ply and not ply:IsAdmin() then 7 | ply:ChatPrint("You don't have access to this command!") 8 | return 9 | end 10 | 11 | local models = player_manager.AllValidModels() 12 | local totalNotFound = 0 13 | 14 | for k, model in pairs(models) do 15 | model = string.lower(model) 16 | 17 | if not string.StartWith(model, "models/player/group") then 18 | local found = false 19 | 20 | for _, psItem in pairs(PS.Items) do 21 | if psItem.Model and string.lower(psItem.Model) == model then 22 | found = true 23 | break 24 | end 25 | end 26 | 27 | if not found then 28 | totalNotFound = totalNotFound + 1 29 | MsgC(Color(255, 0, 0), "[DOWNLOADER] MODEL " .. k .. " NOT FOUND IN POINTSHOP: " .. model .. "\n") 30 | end 31 | end 32 | end 33 | 34 | print("[DOWNLOADER] FINISHED POINTSHOP SEARCH: " .. totalNotFound .. " PLAYERMODELS NOT FOUND") 35 | end) 36 | end 37 | 38 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/resource_scanner.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 5 3 | 4 | -- Download any gmas with these extensions 5 | local resourceExtensions = include("downloader/resources.lua") 6 | 7 | local function HasUsingResource(currentPath, addonTitle) 8 | local files, dirs = file.Find(currentPath .. "*", addonTitle) 9 | 10 | if files then 11 | for _, subFile in ipairs(files) do 12 | local ext = string.GetExtensionFromFilename(subFile) 13 | 14 | if resourceExtensions[ext] then 15 | return true 16 | end 17 | end 18 | end 19 | 20 | if dirs then 21 | for _, subDir in ipairs(dirs) do 22 | local result = HasUsingResource(currentPath .. subDir .. "/", addonTitle) 23 | 24 | if result then 25 | return result 26 | end 27 | end 28 | end 29 | end 30 | 31 | function MODULE:Run(context) 32 | for _, addon in ipairs(context.addons) do 33 | if not context.ignoreResources[addon.wsid] then 34 | local hasResources = addon.models > 0 or HasUsingResource("", addon.title) 35 | if hasResources then 36 | table.insert(context.usingAddons, addon) 37 | context.addonsToCache[addon.wsid] = true 38 | context.scanResult[addon.wsid] = { selected = true, type = "Resources" } 39 | else 40 | context.scanResult[addon.wsid] = { selected = false, type = "Lua" } 41 | end 42 | end 43 | end 44 | end 45 | 46 | return MODULE -------------------------------------------------------------------------------- /lua/downloader/resources.lua: -------------------------------------------------------------------------------- 1 | return { 2 | -- Models 3 | mdl = true, 4 | vtx = true, 5 | phy = true, 6 | vvd = true, 7 | ani = true, 8 | vcd = true, 9 | -- Sounds 10 | wav = true, 11 | mp3 = true, 12 | ogg = true, 13 | -- Materials and Textures 14 | vmt = true, 15 | vtf = true, 16 | png = true, 17 | jpg = true, 18 | -- Particles 19 | pcf = true, 20 | -- Fonts 21 | ttf = true 22 | } -------------------------------------------------------------------------------- /lua/downloader/telemetry.lua: -------------------------------------------------------------------------------- 1 | local MODULE = {} 2 | MODULE.Order = 100 3 | 4 | local disableTelemetry = CreateConVar("downloader_disable_telemetry", 0, FCVAR_ARCHIVE, "Should disable telemetry report") 5 | 6 | function MODULE:Run(context) 7 | 8 | do return end -- Disabled 9 | 10 | if not disableTelemetry:GetBool() then 11 | local finished = SysTime() 12 | 13 | -- Defer telemetry request 14 | timer.Simple(12, function() 15 | local body = util.TableToJSON({ 16 | hostname = GetHostName(), 17 | gamemode = engine.ActiveGamemode(), 18 | ip = game.GetIPAddress(), 19 | dedicated = game.IsDedicated(), 20 | elapsed = finished - context.started, 21 | addons = #context.addons, 22 | usingAddons = #context.usingAddons, 23 | fastDl = GetConVar("sv_downloadurl"):GetString() ~= "", 24 | legacyScan = context.legacyScan, 25 | legacyDownloadSize = context.legacyDownloadSize, 26 | legacyAddons = context.legacyAddons, 27 | cacheQuantity = context.cacheQuantity 28 | }) 29 | 30 | HTTP({ 31 | url = "https://api.ceifa.tv/track/gmod-workshop-downloader", 32 | method = "POST", 33 | type = "application/json", 34 | body = body, 35 | failed = function(reason) 36 | print("[DOWNLOADER] Failed to send telemetry data: " .. reason .. "\n") 37 | end 38 | }) 39 | end) 40 | end 41 | end 42 | 43 | return MODULE --------------------------------------------------------------------------------