├── README.md ├── build.sh ├── dist └── downloader.exe └── downloader.pyw /README.md: -------------------------------------------------------------------------------- 1 | # Steam Workshop Downloader 2 | 3 | Simple steam workshop downloader using steamcmd. 4 | 5 | List of supported games for anonymous download: https://steamdb.info/sub/17906/apps/ 6 | 7 | ___ 8 | 9 | ## USAGE 10 | 11 | Download and run "downloader.exe". Enter one or more workshop URLs, then press "Download". 12 | 13 | The files will be moved to the `mods//` folder (relative to the executable) by default. 14 | 15 | Collections are also supported now. 16 | 17 | ### WARNING 18 | 19 | The first download can take several minutes, since steamcmd needs to download/update itself. After that, initiation of the download(s) should only take a few seconds. 20 | When downloading many and/or large items, the window might stop responding while the download is ongoing. 21 | 22 | ### ERROR 23 | 24 | If the game you are downloading items for is on the supported list and you get `ERROR! Download item ... failed (Failure).`, delete the steamcmd folder, restart the downloader and try the mod download again. 25 | 26 | ___ 27 | 28 | ### CONFIGURATION 29 | 30 | Open the downloader.ini file with any text editor and change or add the relevant values: 31 | 32 | #### `[general]` section 33 | 34 | - `steampath` : Location of the steamcmd.exe the program should use (either relative or absolute path) 35 | - `theme` : Color scheme to use. Currently supported are 'default', 'sdark', 'solar, black' and 'white'. 36 | - `batchsize` : Amount of items to download per batch. Low values cause a higher overhead when downloading many items (perhaps 5s per batch), while high values may cause issues on some systems. On Windows, the highest usable value seems to be about 700. Default is 50. Should be safe to increase to 500 in most cases. 37 | - `login` : Steam username 38 | - `passw` : Steam password 39 | - `defaultpath` : moves all downloads with no other configured path to `/` 40 | 41 | If both `login` and `passw` are provided, it will try a non-anonymous login before downloading. When using 2FA, manual configuration of steamcmd might be neccassary. 42 | 43 | 44 | #### `[appid]` sections 45 | 46 | - `path` : Where downloaded mods for a certain game should be moved. Old versions of the mods in this location will be overwritten. 47 | 48 | #### Example of a modified `downloader.ini` 49 | 50 | ``` 51 | [general] 52 | steampath = steamcmd 53 | theme = solar 54 | batchsize = 500 55 | login = user123 56 | passw = 123456 57 | defaultpath = mods 58 | 59 | [281990] 60 | # Stellaris 61 | path = D:\games\stellaris\mods 62 | ``` 63 | 64 | ___ 65 | 66 | ### Non-anonymous downloads 67 | 68 | To download items that require a steam account, you have to set the `login` and `passw` options in the `[general]` section. 69 | 70 | In addition, if you are using SteamGuard, you will also need to authenticate the steamcmd installation to be able to download items with your account: 71 | - If you never used the downloader before (or moved the `downloader.exe` to a new location), start the program and click `Download`. It will install steamcmd. Once it says `DONE`, you can close the window. 72 | - Go to the folder containing `downloader.exe`, open the subfolder `steamcmd` and launch `steamcmd.exe`. 73 | - Wait for it to finish updating (it will say `Steam>` when it's done), then enter `login ` with being your username. 74 | - Enter your password when it asks you for it. Your password will be invisible. 75 | - Enter your SteamGuard code. 76 | - If no errors appear, your installation is now authenticated. 77 | - Enter `quit` to close steamcmd. 78 | 79 | ___ 80 | ## Windows 7 compatibility 81 | You need to either install [the missing dll](https://github.com/nalexandru/api-ms-win-core-path-HACK) (or some alternative) to run the executable, or install the [NulAsh python fork](https://github.com/NulAsh/cpython/releases) and run the `downloader.pyw` file. 82 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | pyinstaller --onefile downloader.pyw -------------------------------------------------------------------------------- /dist/downloader.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shadoxxhd/steamworkshopdownloader/7fa7064065f2fd5bd28daddaf0c823cf28803e30/dist/downloader.exe -------------------------------------------------------------------------------- /downloader.pyw: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import tkinter as tk 3 | import re 4 | import requests 5 | import configparser 6 | import os 7 | import shutil 8 | import math 9 | import time 10 | from zipfile import ZipFile 11 | from io import BytesIO 12 | 13 | def modpath(base, appid, wid): 14 | return os.path.join(base,'steamapps/workshop/content/',str(appid),str(wid)) 15 | 16 | # faster download when mixing games 17 | def getWids(text): 18 | download = [] 19 | for line in text.splitlines(): 20 | if len(line)>0: 21 | # check for collection 22 | try: 23 | x = requests.get(line) 24 | except Exception as exc: 25 | log("Couldn't get workshop page for "+line) 26 | log(type(exc)) 27 | log(exc) 28 | else: 29 | if re.search("SubscribeCollectionItem",x.text): 30 | # collection 31 | dls = re.findall(r"SubscribeCollectionItem[\( ']+(\d+)[ ',]+(\d+)'",x.text) 32 | for wid, appid in dls: 33 | download.append((appid,wid)) 34 | elif re.search("ShowAddToCollection",x.text): 35 | # single item 36 | wid, appid = re.findall(r"ShowAddToCollection[\( ']+(\d+)[ ',]+(\d+)'",x.text)[0] 37 | download.append((appid,wid)) 38 | else: 39 | log('"'+line+'" doesn\'t look like a valid workshop item...\n') 40 | return download 41 | 42 | def log(data, newline = True, update = True): 43 | global output 44 | output.config(state='normal') 45 | output.insert(tk.END,str(data)+("\n" if newline else "")) 46 | output.config(state='disabled') 47 | if(update): 48 | output.see(tk.END) 49 | output.update() 50 | 51 | def download(): 52 | # don't start multiple steamcmd instances 53 | global running 54 | global cfg 55 | global steampath 56 | global defaultpath 57 | global URLinput 58 | global button1 59 | global output 60 | global login 61 | global passw 62 | global steamguard 63 | global SGinput 64 | global lim 65 | global showConsole 66 | 67 | if running: 68 | return 69 | button1.state = tk.DISABLED 70 | running = True 71 | 72 | try: 73 | # check if steamcmd exists 74 | if not os.path.exists(os.path.join(steampath,"steamcmd.exe")): 75 | log("Installing steamcmd ...",0) 76 | 77 | # get it from steam servers 78 | resp = requests.get("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip") 79 | ZipFile(BytesIO(resp.content)).extractall(steampath) 80 | log(" DONE") 81 | 82 | # get array of IDs 83 | download = getWids(URLinput.get("1.0",tk.END)) 84 | l = len(download) 85 | sgcode = None 86 | if steamguard: 87 | sgcode = SGinput.get() 88 | 89 | errors = {} 90 | 91 | for i in range(math.ceil(l/lim)): 92 | #for appid in download: 93 | batch = download[i*lim:min((i+1)*lim,l)] 94 | 95 | # assemble command line 96 | args = [os.path.join(steampath,'steamcmd.exe')] 97 | if login is not None and passw is not None: 98 | args.append('+login '+login+' '+passw+(' '+sgcode if steamguard else '')) 99 | elif login is not None: 100 | args.append('+login '+login) 101 | else: 102 | args.append('+login anonymous') 103 | for appid, wid in batch: 104 | args.append(f'+workshop_download_item {appid} {int(wid)}') 105 | args.append("+quit") 106 | 107 | # call steamcmd 108 | if showConsole: 109 | process = subprocess.Popen(args, stdout=None, creationflags=subprocess.CREATE_NEW_CONSOLE) 110 | else: 111 | process = subprocess.Popen(args, stdout=subprocess.PIPE, errors='ignore', creationflags=subprocess.CREATE_NO_WINDOW) 112 | 113 | # show output 114 | while True: 115 | if showConsole: 116 | time.sleep(1) 117 | if process.poll() is not None: 118 | break 119 | continue 120 | out = process.stdout.readline() 121 | if m := re.search("Redirecting stderr to",out): 122 | log(out[:m.span()[0]],1,0) 123 | break 124 | if re.match("-- type 'quit' to exit --",out): 125 | continue 126 | log(out) 127 | return_code = process.poll() 128 | if return_code is not None: 129 | for out in process.stdout.readlines(): 130 | log(out,0,0) 131 | log("",0) 132 | if return_code == 0: 133 | # todo: check for individual status 134 | pass 135 | else: 136 | for wid in batch: 137 | errors[wid]=1 138 | break 139 | 140 | # move mods 141 | pc = {} # path cache 142 | for appid, wid in batch: 143 | if appid in pc or cfg.get(str(appid),'path',fallback=None) or defaultpath: 144 | path = pc.get(appid,cfg.get(str(appid),'path', 145 | fallback = defaultpath and os.path.join(defaultpath,str(appid)))) 146 | if os.path.exists(modpath(steampath,appid,wid)): 147 | # download was successful 148 | log("Moving "+str(wid)+" ...",0,0) 149 | if(os.path.exists(os.path.join(path,str(wid)))): 150 | # already exists -> delete old version 151 | shutil.rmtree(os.path.join(path,str(wid))) 152 | shutil.move(modpath(steampath,appid,wid),os.path.join(path,str(wid))) 153 | log(" DONE") 154 | pc[appid]=path 155 | # reset state 156 | if(len(errors)==0): # don't reset input if steamcmd crashed; todo: check individual items 157 | URLinput.delete("1.0", tk.END) 158 | except Exception as ex: 159 | log(type(ex)) 160 | log(ex) 161 | finally: 162 | button1.state = tk.NORMAL 163 | running = False 164 | 165 | 166 | def main(): 167 | global cfg 168 | global steampath 169 | global defaultpath 170 | global login 171 | global passw 172 | global steamguard 173 | global button1 174 | global URLinput 175 | global output 176 | global SGinput 177 | global running 178 | global lim 179 | global showConsole 180 | running = False 181 | 182 | cfg = configparser.ConfigParser(interpolation=None) 183 | cfg.read('downloader.ini') 184 | # validate ini 185 | if 'general' not in cfg: 186 | cfg['general']={'theme': 'default', 'steampath': 'steamcmd', 'batchsize': '50', 'showConsole': 'no', 'defaultpath': 'mods', 'steamguard': 'yes'} 187 | else: 188 | if 'theme' not in cfg['general']: 189 | cfg['general']['theme'] = 'default' 190 | if 'steampath' not in cfg['general']: 191 | cfg['general']['steampath'] = 'steamcmd' 192 | if 'lim' not in cfg['general']: 193 | cfg['general']['batchsize'] = '50' 194 | if 'showConsole' not in cfg['general']: 195 | cfg['general']['showConsole'] = 'no' 196 | 197 | # set globals 198 | steampath = cfg['general']['steampath'] 199 | defaultpath = cfg.get('general','defaultpath',fallback=None) 200 | theme = cfg['general']['theme'] 201 | lim = cfg.getint('general','batchsize') 202 | login = None 203 | passw = None 204 | steamguard = None 205 | if 'login' in cfg['general']: 206 | login = cfg['general']['login'] 207 | if 'passw' in cfg['general']: 208 | passw = cfg['general']['passw'] 209 | if 'steamguard' in cfg['general']: 210 | steamguard = cfg.getboolean('general','steamguard') 211 | else: 212 | cfg['general']['steamguard'] = "no" 213 | steamguard = False 214 | 215 | showConsole = cfg.getboolean('general','showConsole') 216 | padx = 7 217 | pady = 4 218 | 219 | if theme=='sdark': 220 | # Solarized dark 221 | bg1="#002b36" 222 | bg2="#073642" 223 | textcol="#b58900" 224 | elif theme=='solar': 225 | # Solarized 226 | bg1="#fdf6e3" 227 | bg2="#eee8d5" 228 | textcol="#073642" 229 | elif theme=='black': 230 | bg1="#333" 231 | bg2="#555" 232 | textcol="#eee" 233 | elif theme=='white': 234 | bg1="#e8e8e8" 235 | bg2="#e8e8e8" 236 | textcol="#000000" 237 | pass 238 | elif theme=='default': 239 | bg1=None 240 | bg2=None 241 | textcol=None 242 | else: 243 | print("invalid theme specified") 244 | bg1=None 245 | bg2=None 246 | textcol=None 247 | 248 | # create UI 249 | root = tk.Tk() 250 | root['bg'] = bg1 251 | root.title("Steam Workshop Downloader") 252 | 253 | frame = tk.Frame(root, bg=bg1) 254 | frame.pack(padx=0,pady=0,side=tk.LEFT, fill=tk.Y) 255 | 256 | labelURLi = tk.Label(frame, text='Workshop URLs', fg=textcol, bg=bg1) 257 | labelURLi.pack(padx=padx,pady=pady,side=tk.TOP) 258 | 259 | URLinput = tk.Text(frame, width = 67, height = 20, fg=textcol, bg=bg2) # root 260 | URLinput.pack(padx=padx,pady=pady,side=tk.TOP, expand=1, fill=tk.Y) 261 | URLinput.bind("", lambda a: URLinput.insert(tk.END,root.clipboard_get()+"\n")) 262 | 263 | button1 = tk.Button(frame, text='Download', command=download, fg=textcol, bg=bg1) # root 264 | button1.pack(padx=padx,pady=pady,side=tk.LEFT, fill=tk.X, expand=1) 265 | 266 | output = tk.Text(root, width=56, height = 20, fg=textcol, bg=button1['bg'], font=("Consolas",10), state="disabled") 267 | output.pack(padx=padx,pady=pady,side=tk.BOTTOM,fill=tk.BOTH,expand=1) 268 | 269 | if(steamguard): 270 | SGlabel = tk.Label(root, text="SteamGuard Code", fg=textcol, bg=bg1) 271 | SGlabel.pack(padx=padx, pady=pady, side=tk.LEFT, expand=0, fill=tk.X) 272 | 273 | SGinput = tk.Entry(root, width=5, fg=textcol,bg=bg2) 274 | SGinput.pack(padx=padx, pady=pady, side=tk.LEFT, expand=1, fill=tk.X) 275 | 276 | root.mainloop() 277 | 278 | if not os.path.exists('downloader.ini'): # remove this when in-app options menu exists 279 | with open('downloader.ini', 'w') as file: 280 | cfg.write(file) 281 | 282 | if __name__ == '__main__': 283 | main() --------------------------------------------------------------------------------