modiodirect 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modiodirect-1.0.0/LICENSE +21 -0
- modiodirect-1.0.0/MANIFEST.in +2 -0
- modiodirect-1.0.0/ModioDirect.py +731 -0
- modiodirect-1.0.0/PKG-INFO +122 -0
- modiodirect-1.0.0/README.md +99 -0
- modiodirect-1.0.0/modiodirect.egg-info/PKG-INFO +122 -0
- modiodirect-1.0.0/modiodirect.egg-info/SOURCES.txt +11 -0
- modiodirect-1.0.0/modiodirect.egg-info/dependency_links.txt +1 -0
- modiodirect-1.0.0/modiodirect.egg-info/entry_points.txt +2 -0
- modiodirect-1.0.0/modiodirect.egg-info/requires.txt +2 -0
- modiodirect-1.0.0/modiodirect.egg-info/top_level.txt +1 -0
- modiodirect-1.0.0/pyproject.toml +37 -0
- modiodirect-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TheRootExec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ModioDirect by TheRootExec
|
|
3
|
+
# Direct Downloader for mod.io (official API only)
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import subprocess
|
|
11
|
+
from urllib.parse import urlparse, unquote
|
|
12
|
+
|
|
13
|
+
# Optional tqdm
|
|
14
|
+
try:
|
|
15
|
+
from tqdm import tqdm # type: ignore
|
|
16
|
+
except Exception:
|
|
17
|
+
tqdm = None
|
|
18
|
+
|
|
19
|
+
# Required requests (may be installed on demand)
|
|
20
|
+
try:
|
|
21
|
+
import requests # type: ignore
|
|
22
|
+
except Exception:
|
|
23
|
+
requests = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
API_BASE = "https://api.mod.io/v1"
|
|
27
|
+
CONFIG_NAME = "config.json"
|
|
28
|
+
USER_AGENT = "ModioDirect/1.1 (TheRootExec)"
|
|
29
|
+
DOWNLOAD_DIR = "downloads"
|
|
30
|
+
URL_REGEX = re.compile(
|
|
31
|
+
r"^https?://(?:www\.)?mod\.io/g/([^/]+)/m/([^/?#]+)",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_error(msg):
|
|
37
|
+
print(f"[Error] {msg}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def print_info(msg):
|
|
41
|
+
print(f"[Info] {msg}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_banner():
|
|
45
|
+
print(r" __ __ _ _ _____ _ _ ")
|
|
46
|
+
print(r"| \/ | ___ __| (_) ___ | __ \(_)_ __ ___ ___| |_ ")
|
|
47
|
+
print(r"| |\/| |/ _ \ / _` | |/ _ \| | | | | '__/ _ \/ __| __|")
|
|
48
|
+
print(r"| | | | (_) | (_| | | (_) | |__| | | | | __/ (__| |_ ")
|
|
49
|
+
print(r"|_| |_|\___/ \__,_|_|\___/|_____/|_|_| \___|\___|\__|")
|
|
50
|
+
print("\n ModioDirect Downloader Tool")
|
|
51
|
+
print(" by TheRootExec")
|
|
52
|
+
print("-------------------------------------------------------")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def try_auto_install_requests():
|
|
56
|
+
global requests
|
|
57
|
+
if requests is not None:
|
|
58
|
+
return True
|
|
59
|
+
print_error("The 'requests' library is required but not installed.")
|
|
60
|
+
choice = input("Install requirements now? (y/n): ").strip().lower()
|
|
61
|
+
if choice != "y":
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
cmd = [sys.executable, "-m", "pip", "install", "requests"]
|
|
65
|
+
subprocess.run(cmd, check=False)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
print_error(f"Failed to run pip: {exc}")
|
|
68
|
+
return False
|
|
69
|
+
# Re-try import
|
|
70
|
+
try:
|
|
71
|
+
import requests as _requests # type: ignore
|
|
72
|
+
requests = _requests
|
|
73
|
+
return True
|
|
74
|
+
except Exception:
|
|
75
|
+
print_error("Requests is still not available after install attempt.")
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def safe_json(resp):
|
|
80
|
+
try:
|
|
81
|
+
return resp.json()
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def safe_request(method, url, **kwargs):
|
|
87
|
+
if requests is None:
|
|
88
|
+
print_error("The 'requests' library is not installed. Install it with: pip install requests")
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return requests.request(method, url, **kwargs)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
print_error(f"Network error: {exc}")
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_config(config_path):
|
|
98
|
+
if not os.path.isfile(config_path):
|
|
99
|
+
return {}
|
|
100
|
+
try:
|
|
101
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
102
|
+
data = json.load(f)
|
|
103
|
+
if isinstance(data, dict):
|
|
104
|
+
return data
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def save_config(config_path, data):
|
|
111
|
+
try:
|
|
112
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
113
|
+
json.dump(data, f, indent=2)
|
|
114
|
+
return True
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
print_error(f"Failed to save config: {exc}")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_api_key(api_key):
|
|
121
|
+
# Test API key by listing 1 game
|
|
122
|
+
url = f"{API_BASE}/games"
|
|
123
|
+
params = {"api_key": api_key, "limit": 1}
|
|
124
|
+
headers = {"User-Agent": USER_AGENT}
|
|
125
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
126
|
+
if resp is None:
|
|
127
|
+
return False, "Network error or requests missing."
|
|
128
|
+
if resp.status_code == 401:
|
|
129
|
+
return False, "Invalid API key (401 Unauthorized)."
|
|
130
|
+
if resp.status_code == 429:
|
|
131
|
+
return False, "Rate limited (429). Try again later."
|
|
132
|
+
if resp.status_code >= 400:
|
|
133
|
+
return False, f"API error ({resp.status_code})."
|
|
134
|
+
data = safe_json(resp)
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
return False, "Empty or invalid API response."
|
|
137
|
+
return True, "API key validated."
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def prompt_api_key(config_path, use_config):
|
|
141
|
+
config = {}
|
|
142
|
+
api_key = ""
|
|
143
|
+
if use_config:
|
|
144
|
+
config = load_config(config_path)
|
|
145
|
+
if isinstance(config, dict):
|
|
146
|
+
api_key = str(config.get("api_key", "")).strip()
|
|
147
|
+
|
|
148
|
+
while True:
|
|
149
|
+
if not api_key:
|
|
150
|
+
api_key = input("Enter your mod.io API key: ").strip()
|
|
151
|
+
if not api_key:
|
|
152
|
+
print_error("API key cannot be empty.")
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
ok, msg = validate_api_key(api_key)
|
|
156
|
+
if ok:
|
|
157
|
+
print_info(msg)
|
|
158
|
+
if use_config:
|
|
159
|
+
config["api_key"] = api_key
|
|
160
|
+
save_config(config_path, config)
|
|
161
|
+
return api_key
|
|
162
|
+
print_error(msg)
|
|
163
|
+
api_key = "" # force re-prompt
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def prompt_mod_url():
|
|
167
|
+
while True:
|
|
168
|
+
url = input("Enter mod.io mod URL (or 'q' to exit): ").strip()
|
|
169
|
+
if url.lower() in ("q", "quit", "exit"):
|
|
170
|
+
return None, None
|
|
171
|
+
if not url:
|
|
172
|
+
print_error("URL cannot be empty.")
|
|
173
|
+
continue
|
|
174
|
+
match = URL_REGEX.search(url)
|
|
175
|
+
if not match:
|
|
176
|
+
print_error("Invalid mod.io URL. Expected: https://mod.io/g/<game_slug>/m/<mod_slug>")
|
|
177
|
+
continue
|
|
178
|
+
game_slug = match.group(1).strip()
|
|
179
|
+
mod_slug = match.group(2).strip()
|
|
180
|
+
if not game_slug or not mod_slug:
|
|
181
|
+
print_error("Could not parse game_slug or mod_slug from URL.")
|
|
182
|
+
continue
|
|
183
|
+
return game_slug, mod_slug
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_game_id(api_key, game_slug):
|
|
187
|
+
url = f"{API_BASE}/games"
|
|
188
|
+
# Filtering uses direct field parameters, e.g. name_id=<slug>
|
|
189
|
+
params = {"api_key": api_key, "name_id": game_slug, "limit": 1}
|
|
190
|
+
headers = {"User-Agent": USER_AGENT}
|
|
191
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
192
|
+
if resp is None:
|
|
193
|
+
return None, "Network error while resolving game."
|
|
194
|
+
if resp.status_code == 401:
|
|
195
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
196
|
+
if resp.status_code == 429:
|
|
197
|
+
return None, "Rate limited (429) while resolving game."
|
|
198
|
+
if resp.status_code >= 400:
|
|
199
|
+
return None, f"API error ({resp.status_code}) while resolving game."
|
|
200
|
+
data = safe_json(resp)
|
|
201
|
+
if not isinstance(data, dict):
|
|
202
|
+
return None, "Empty or invalid API response while resolving game."
|
|
203
|
+
items = data.get("data")
|
|
204
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
205
|
+
# Fallback: search and match by name_id/slug
|
|
206
|
+
fallback_id, fallback_err = fallback_search_game_id(api_key, game_slug)
|
|
207
|
+
if fallback_id is not None:
|
|
208
|
+
return fallback_id, None
|
|
209
|
+
return None, fallback_err or "Game not found for provided game_slug."
|
|
210
|
+
# Never index without length check:
|
|
211
|
+
game = items[0] if len(items) > 0 else None
|
|
212
|
+
if not isinstance(game, dict):
|
|
213
|
+
return None, "Unexpected game data format."
|
|
214
|
+
game_id = game.get("id")
|
|
215
|
+
if not isinstance(game_id, int):
|
|
216
|
+
return None, "Missing game_id in API response."
|
|
217
|
+
return game_id, None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def match_slug(item, slug):
|
|
221
|
+
if not isinstance(item, dict):
|
|
222
|
+
return False
|
|
223
|
+
name_id = item.get("name_id")
|
|
224
|
+
if isinstance(name_id, str) and name_id.lower() == slug.lower():
|
|
225
|
+
return True
|
|
226
|
+
alt_slug = item.get("slug")
|
|
227
|
+
if isinstance(alt_slug, str) and alt_slug.lower() == slug.lower():
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def fallback_search_game_id(api_key, game_slug):
|
|
233
|
+
url = f"{API_BASE}/games"
|
|
234
|
+
# _q is the documented full-text search parameter
|
|
235
|
+
params = {"api_key": api_key, "_q": game_slug, "limit": 100}
|
|
236
|
+
headers = {"User-Agent": USER_AGENT}
|
|
237
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
238
|
+
if resp is None:
|
|
239
|
+
return None, "Network error while searching game."
|
|
240
|
+
if resp.status_code == 401:
|
|
241
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
242
|
+
if resp.status_code == 429:
|
|
243
|
+
return None, "Rate limited (429) while searching game."
|
|
244
|
+
if resp.status_code >= 400:
|
|
245
|
+
return None, f"API error ({resp.status_code}) while searching game."
|
|
246
|
+
data = safe_json(resp)
|
|
247
|
+
if not isinstance(data, dict):
|
|
248
|
+
return None, "Empty or invalid API response while searching game."
|
|
249
|
+
items = data.get("data")
|
|
250
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
251
|
+
return None, "Game not found for provided game_slug."
|
|
252
|
+
for item in items:
|
|
253
|
+
if match_slug(item, game_slug):
|
|
254
|
+
game_id = item.get("id") if isinstance(item, dict) else None
|
|
255
|
+
if isinstance(game_id, int):
|
|
256
|
+
return game_id, None
|
|
257
|
+
return None, "Game not found for provided game_slug."
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def resolve_mod_id(api_key, game_id, mod_slug):
|
|
261
|
+
url = f"{API_BASE}/games/{game_id}/mods"
|
|
262
|
+
# Filtering uses direct field parameters, e.g. name_id=<slug>
|
|
263
|
+
params = {"api_key": api_key, "name_id": mod_slug, "limit": 1}
|
|
264
|
+
headers = {"User-Agent": USER_AGENT}
|
|
265
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
266
|
+
if resp is None:
|
|
267
|
+
return None, "Network error while resolving mod."
|
|
268
|
+
if resp.status_code == 401:
|
|
269
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
270
|
+
if resp.status_code == 429:
|
|
271
|
+
return None, "Rate limited (429) while resolving mod."
|
|
272
|
+
if resp.status_code == 404:
|
|
273
|
+
# Some API keys are restricted; try global mods endpoint as fallback
|
|
274
|
+
fallback_id, fallback_err = resolve_mod_id_global(api_key, game_id, mod_slug)
|
|
275
|
+
if fallback_id is not None:
|
|
276
|
+
return fallback_id, None
|
|
277
|
+
return None, fallback_err or "API returned 404 while resolving mod. The game or mod may be inaccessible with this API key."
|
|
278
|
+
if resp.status_code >= 400:
|
|
279
|
+
return None, f"API error ({resp.status_code}) while resolving mod."
|
|
280
|
+
data = safe_json(resp)
|
|
281
|
+
if not isinstance(data, dict):
|
|
282
|
+
return None, "Empty or invalid API response while resolving mod."
|
|
283
|
+
items = data.get("data")
|
|
284
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
285
|
+
# Fallback: search and match by name_id/slug
|
|
286
|
+
fallback_id, fallback_err = fallback_search_mod_id(api_key, game_id, mod_slug)
|
|
287
|
+
if fallback_id is not None:
|
|
288
|
+
return fallback_id, None
|
|
289
|
+
return None, fallback_err or "Mod not found for provided mod_slug."
|
|
290
|
+
mod = items[0] if len(items) > 0 else None
|
|
291
|
+
if not isinstance(mod, dict):
|
|
292
|
+
return None, "Unexpected mod data format."
|
|
293
|
+
mod_id = mod.get("id")
|
|
294
|
+
if not isinstance(mod_id, int):
|
|
295
|
+
return None, "Missing mod_id in API response."
|
|
296
|
+
return mod_id, None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def resolve_mod_id_global(api_key, game_id, mod_slug):
|
|
300
|
+
url = f"{API_BASE}/mods"
|
|
301
|
+
params = {"api_key": api_key, "game_id": game_id, "name_id": mod_slug, "limit": 1}
|
|
302
|
+
headers = {"User-Agent": USER_AGENT}
|
|
303
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
304
|
+
if resp is None:
|
|
305
|
+
return None, "Network error while resolving mod (global)."
|
|
306
|
+
if resp.status_code == 401:
|
|
307
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
308
|
+
if resp.status_code == 429:
|
|
309
|
+
return None, "Rate limited (429) while resolving mod (global)."
|
|
310
|
+
if resp.status_code == 404:
|
|
311
|
+
# Try a global search by query and filter client-side
|
|
312
|
+
search_id, search_err = resolve_mod_id_global_search(api_key, game_id, mod_slug)
|
|
313
|
+
if search_id is not None:
|
|
314
|
+
return search_id, None
|
|
315
|
+
# If mod_slug is numeric, try direct mod lookup
|
|
316
|
+
numeric_id, numeric_err = resolve_mod_id_numeric(api_key, game_id, mod_slug)
|
|
317
|
+
if numeric_id is not None:
|
|
318
|
+
return numeric_id, None
|
|
319
|
+
return None, search_err or numeric_err or "API error (404) while resolving mod (global)."
|
|
320
|
+
if resp.status_code >= 400:
|
|
321
|
+
return None, f"API error ({resp.status_code}) while resolving mod (global)."
|
|
322
|
+
data = safe_json(resp)
|
|
323
|
+
if not isinstance(data, dict):
|
|
324
|
+
return None, "Empty or invalid API response while resolving mod (global)."
|
|
325
|
+
items = data.get("data")
|
|
326
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
327
|
+
return None, "Mod not found for provided mod_slug."
|
|
328
|
+
mod = items[0] if len(items) > 0 else None
|
|
329
|
+
if not isinstance(mod, dict):
|
|
330
|
+
return None, "Unexpected mod data format."
|
|
331
|
+
mod_id = mod.get("id")
|
|
332
|
+
if not isinstance(mod_id, int):
|
|
333
|
+
return None, "Missing mod_id in API response."
|
|
334
|
+
return mod_id, None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def resolve_mod_id_global_search(api_key, game_id, mod_slug):
|
|
338
|
+
url = f"{API_BASE}/mods"
|
|
339
|
+
params = {"api_key": api_key, "_q": mod_slug, "limit": 100}
|
|
340
|
+
headers = {"User-Agent": USER_AGENT}
|
|
341
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
342
|
+
if resp is None:
|
|
343
|
+
return None, "Network error while searching mod (global)."
|
|
344
|
+
if resp.status_code == 401:
|
|
345
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
346
|
+
if resp.status_code == 429:
|
|
347
|
+
return None, "Rate limited (429) while searching mod (global)."
|
|
348
|
+
if resp.status_code >= 400:
|
|
349
|
+
return None, f"API error ({resp.status_code}) while searching mod (global)."
|
|
350
|
+
data = safe_json(resp)
|
|
351
|
+
if not isinstance(data, dict):
|
|
352
|
+
return None, "Empty or invalid API response while searching mod (global)."
|
|
353
|
+
items = data.get("data")
|
|
354
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
355
|
+
return None, "Mod not found for provided mod_slug."
|
|
356
|
+
for item in items:
|
|
357
|
+
if not isinstance(item, dict):
|
|
358
|
+
continue
|
|
359
|
+
# Ensure game_id matches
|
|
360
|
+
item_game_id = item.get("game_id")
|
|
361
|
+
if isinstance(item_game_id, int) and item_game_id != game_id:
|
|
362
|
+
continue
|
|
363
|
+
if match_slug(item, mod_slug):
|
|
364
|
+
mod_id = item.get("id")
|
|
365
|
+
if isinstance(mod_id, int):
|
|
366
|
+
return mod_id, None
|
|
367
|
+
return None, "Mod not found for provided mod_slug."
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def resolve_mod_id_numeric(api_key, game_id, mod_slug):
|
|
371
|
+
# If the slug is numeric, try direct mod ID
|
|
372
|
+
if not isinstance(mod_slug, str) or not mod_slug.isdigit():
|
|
373
|
+
return None, None
|
|
374
|
+
mod_id = int(mod_slug)
|
|
375
|
+
url = f"{API_BASE}/games/{game_id}/mods/{mod_id}"
|
|
376
|
+
params = {"api_key": api_key}
|
|
377
|
+
headers = {"User-Agent": USER_AGENT}
|
|
378
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
379
|
+
if resp is None:
|
|
380
|
+
return None, "Network error while resolving mod by numeric ID."
|
|
381
|
+
if resp.status_code == 401:
|
|
382
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
383
|
+
if resp.status_code == 429:
|
|
384
|
+
return None, "Rate limited (429) while resolving mod by numeric ID."
|
|
385
|
+
if resp.status_code >= 400:
|
|
386
|
+
return None, f"API error ({resp.status_code}) while resolving mod by numeric ID."
|
|
387
|
+
data = safe_json(resp)
|
|
388
|
+
if not isinstance(data, dict):
|
|
389
|
+
return None, "Empty or invalid API response while resolving mod by numeric ID."
|
|
390
|
+
mid = data.get("id")
|
|
391
|
+
if not isinstance(mid, int):
|
|
392
|
+
return None, "Missing mod_id in API response."
|
|
393
|
+
return mid, None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def fallback_search_mod_id(api_key, game_id, mod_slug):
|
|
397
|
+
url = f"{API_BASE}/games/{game_id}/mods"
|
|
398
|
+
# _q is the documented full-text search parameter
|
|
399
|
+
params = {"api_key": api_key, "_q": mod_slug, "limit": 100}
|
|
400
|
+
headers = {"User-Agent": USER_AGENT}
|
|
401
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
402
|
+
if resp is None:
|
|
403
|
+
return None, "Network error while searching mod."
|
|
404
|
+
if resp.status_code == 401:
|
|
405
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
406
|
+
if resp.status_code == 429:
|
|
407
|
+
return None, "Rate limited (429) while searching mod."
|
|
408
|
+
if resp.status_code >= 400:
|
|
409
|
+
return None, f"API error ({resp.status_code}) while searching mod."
|
|
410
|
+
data = safe_json(resp)
|
|
411
|
+
if not isinstance(data, dict):
|
|
412
|
+
return None, "Empty or invalid API response while searching mod."
|
|
413
|
+
items = data.get("data")
|
|
414
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
415
|
+
return None, "Mod not found for provided mod_slug."
|
|
416
|
+
for item in items:
|
|
417
|
+
if match_slug(item, mod_slug):
|
|
418
|
+
mod_id = item.get("id") if isinstance(item, dict) else None
|
|
419
|
+
if isinstance(mod_id, int):
|
|
420
|
+
return mod_id, None
|
|
421
|
+
return None, "Mod not found for provided mod_slug."
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def fetch_game_details(api_key, game_id):
|
|
425
|
+
url = f"{API_BASE}/games/{game_id}"
|
|
426
|
+
params = {"api_key": api_key}
|
|
427
|
+
headers = {"User-Agent": USER_AGENT}
|
|
428
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
429
|
+
if resp is None:
|
|
430
|
+
return None, "Network error while fetching game details."
|
|
431
|
+
if resp.status_code == 401:
|
|
432
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
433
|
+
if resp.status_code == 429:
|
|
434
|
+
return None, "Rate limited (429) while fetching game details."
|
|
435
|
+
if resp.status_code == 404:
|
|
436
|
+
return None, "Game not accessible (404). The game may be private, unpublished, or require OAuth access."
|
|
437
|
+
if resp.status_code >= 400:
|
|
438
|
+
return None, f"API error ({resp.status_code}) while fetching game details."
|
|
439
|
+
data = safe_json(resp)
|
|
440
|
+
if not isinstance(data, dict):
|
|
441
|
+
return None, "Empty or invalid API response while fetching game details."
|
|
442
|
+
return data, None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def fetch_mod_details(api_key, game_id, mod_id):
|
|
446
|
+
url = f"{API_BASE}/games/{game_id}/mods/{mod_id}"
|
|
447
|
+
params = {"api_key": api_key}
|
|
448
|
+
headers = {"User-Agent": USER_AGENT}
|
|
449
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=15)
|
|
450
|
+
if resp is None:
|
|
451
|
+
return None, "Network error while fetching mod details."
|
|
452
|
+
if resp.status_code == 401:
|
|
453
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
454
|
+
if resp.status_code == 429:
|
|
455
|
+
return None, "Rate limited (429) while fetching mod details."
|
|
456
|
+
if resp.status_code >= 400:
|
|
457
|
+
return None, f"API error ({resp.status_code}) while fetching mod details."
|
|
458
|
+
data = safe_json(resp)
|
|
459
|
+
if not isinstance(data, dict):
|
|
460
|
+
return None, "Empty or invalid API response while fetching mod details."
|
|
461
|
+
return data, None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def fetch_mod_files(api_key, game_id, mod_id):
|
|
465
|
+
url = f"{API_BASE}/games/{game_id}/mods/{mod_id}/files"
|
|
466
|
+
params = {"api_key": api_key, "limit": 100}
|
|
467
|
+
headers = {"User-Agent": USER_AGENT}
|
|
468
|
+
resp = safe_request("GET", url, params=params, headers=headers, timeout=20)
|
|
469
|
+
if resp is None:
|
|
470
|
+
return None, "Network error while fetching mod files."
|
|
471
|
+
if resp.status_code == 401:
|
|
472
|
+
return None, "Invalid API key (401 Unauthorized)."
|
|
473
|
+
if resp.status_code == 429:
|
|
474
|
+
return None, "Rate limited (429) while fetching mod files."
|
|
475
|
+
if resp.status_code >= 400:
|
|
476
|
+
return None, f"API error ({resp.status_code}) while fetching mod files."
|
|
477
|
+
data = safe_json(resp)
|
|
478
|
+
if not isinstance(data, dict):
|
|
479
|
+
return None, "Empty or invalid API response while fetching mod files."
|
|
480
|
+
items = data.get("data")
|
|
481
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
482
|
+
return None, "No mod files found."
|
|
483
|
+
return items, None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def select_latest_file(files):
|
|
487
|
+
if not isinstance(files, list) or len(files) == 0:
|
|
488
|
+
return None
|
|
489
|
+
latest = None
|
|
490
|
+
latest_date = -1
|
|
491
|
+
for f in files:
|
|
492
|
+
if not isinstance(f, dict):
|
|
493
|
+
continue
|
|
494
|
+
date_added = f.get("date_added")
|
|
495
|
+
if isinstance(date_added, int) and date_added > latest_date:
|
|
496
|
+
latest_date = date_added
|
|
497
|
+
latest = f
|
|
498
|
+
return latest
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def extract_download_info(file_obj):
|
|
502
|
+
if not isinstance(file_obj, dict):
|
|
503
|
+
return None, None
|
|
504
|
+
download = file_obj.get("download")
|
|
505
|
+
if not isinstance(download, dict):
|
|
506
|
+
return None, None
|
|
507
|
+
binary_url = download.get("binary_url")
|
|
508
|
+
if not isinstance(binary_url, str) or not binary_url.strip():
|
|
509
|
+
return None, None
|
|
510
|
+
# Fix escaped slashes
|
|
511
|
+
binary_url = binary_url.replace("\\/", "/")
|
|
512
|
+
# Determine filename
|
|
513
|
+
filename = file_obj.get("filename")
|
|
514
|
+
if not isinstance(filename, str) or not filename.strip():
|
|
515
|
+
# Try from URL
|
|
516
|
+
try:
|
|
517
|
+
parsed = urlparse(binary_url)
|
|
518
|
+
path = parsed.path or ""
|
|
519
|
+
basename = os.path.basename(path)
|
|
520
|
+
filename = unquote(basename) if basename else "modfile.bin"
|
|
521
|
+
except Exception:
|
|
522
|
+
filename = "modfile.bin"
|
|
523
|
+
return binary_url, filename
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def download_file(url, filename):
|
|
527
|
+
headers = {"User-Agent": USER_AGENT}
|
|
528
|
+
# Attempt twice max
|
|
529
|
+
for attempt in range(1, 3):
|
|
530
|
+
resp = safe_request("GET", url, headers=headers, stream=True, timeout=30)
|
|
531
|
+
if resp is None:
|
|
532
|
+
print_error("Download failed due to network error.")
|
|
533
|
+
if attempt == 2:
|
|
534
|
+
return False
|
|
535
|
+
time.sleep(1)
|
|
536
|
+
continue
|
|
537
|
+
if resp.status_code == 429:
|
|
538
|
+
print_error("Rate limited (429) during download.")
|
|
539
|
+
if attempt == 2:
|
|
540
|
+
return False
|
|
541
|
+
time.sleep(2)
|
|
542
|
+
continue
|
|
543
|
+
if resp.status_code >= 400:
|
|
544
|
+
print_error(f"Download failed with status {resp.status_code}.")
|
|
545
|
+
if attempt == 2:
|
|
546
|
+
return False
|
|
547
|
+
time.sleep(1)
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
total = resp.headers.get("Content-Length")
|
|
551
|
+
try:
|
|
552
|
+
total_bytes = int(total) if total is not None else None
|
|
553
|
+
except Exception:
|
|
554
|
+
total_bytes = None
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
|
558
|
+
filename = os.path.join(DOWNLOAD_DIR, filename)
|
|
559
|
+
with open(filename, "wb") as f:
|
|
560
|
+
if tqdm is not None and total_bytes is not None:
|
|
561
|
+
with tqdm(total=total_bytes, unit="B", unit_scale=True, desc="Downloading") as bar:
|
|
562
|
+
for chunk in resp.iter_content(chunk_size=1024 * 256):
|
|
563
|
+
if not chunk:
|
|
564
|
+
continue
|
|
565
|
+
f.write(chunk)
|
|
566
|
+
bar.update(len(chunk))
|
|
567
|
+
else:
|
|
568
|
+
downloaded = 0
|
|
569
|
+
last_print = 0
|
|
570
|
+
for chunk in resp.iter_content(chunk_size=1024 * 256):
|
|
571
|
+
if not chunk:
|
|
572
|
+
continue
|
|
573
|
+
f.write(chunk)
|
|
574
|
+
downloaded += len(chunk)
|
|
575
|
+
if total_bytes:
|
|
576
|
+
pct = int((downloaded / total_bytes) * 100)
|
|
577
|
+
if pct >= last_print + 5 or pct == 100:
|
|
578
|
+
print(f"Downloading... {pct}%")
|
|
579
|
+
last_print = pct
|
|
580
|
+
if total_bytes is None:
|
|
581
|
+
print_info("Download complete (size unknown).")
|
|
582
|
+
return True
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
print_error(f"Failed to write file: {exc}")
|
|
585
|
+
if attempt == 2:
|
|
586
|
+
return False
|
|
587
|
+
time.sleep(1)
|
|
588
|
+
return False
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def main():
|
|
592
|
+
print_banner()
|
|
593
|
+
if not try_auto_install_requests():
|
|
594
|
+
print_error("Cannot continue without 'requests'.")
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_NAME)
|
|
598
|
+
use_config = "--no-config" not in sys.argv
|
|
599
|
+
api_key = prompt_api_key(config_path, use_config)
|
|
600
|
+
|
|
601
|
+
while True:
|
|
602
|
+
game_slug, mod_slug = prompt_mod_url()
|
|
603
|
+
if game_slug is None and mod_slug is None:
|
|
604
|
+
print_info("Thanks for using ModioDirect.")
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
game_id, err = resolve_game_id(api_key, game_slug)
|
|
608
|
+
if err:
|
|
609
|
+
print_error(err)
|
|
610
|
+
continue
|
|
611
|
+
game_details, gerr = fetch_game_details(api_key, game_id)
|
|
612
|
+
if gerr:
|
|
613
|
+
print_error(gerr)
|
|
614
|
+
continue
|
|
615
|
+
# Surface the resolved game name early
|
|
616
|
+
game_name = ""
|
|
617
|
+
if isinstance(game_details, dict):
|
|
618
|
+
name = game_details.get("name")
|
|
619
|
+
if isinstance(name, str):
|
|
620
|
+
game_name = name
|
|
621
|
+
if game_name:
|
|
622
|
+
print_info(f"Game : {game_name}")
|
|
623
|
+
|
|
624
|
+
mod_id, err = resolve_mod_id(api_key, game_id, mod_slug)
|
|
625
|
+
if err:
|
|
626
|
+
print_error(err)
|
|
627
|
+
continue
|
|
628
|
+
mod_details, merr = fetch_mod_details(api_key, game_id, mod_id)
|
|
629
|
+
if merr:
|
|
630
|
+
print_error(merr)
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
mod_name = ""
|
|
634
|
+
if isinstance(mod_details, dict):
|
|
635
|
+
name = mod_details.get("name")
|
|
636
|
+
if isinstance(name, str):
|
|
637
|
+
mod_name = name
|
|
638
|
+
|
|
639
|
+
if mod_name:
|
|
640
|
+
print_info(f"Mod : {mod_name}")
|
|
641
|
+
|
|
642
|
+
files, err = fetch_mod_files(api_key, game_id, mod_id)
|
|
643
|
+
if err:
|
|
644
|
+
print_error(err)
|
|
645
|
+
continue
|
|
646
|
+
|
|
647
|
+
latest_file = select_latest_file(files)
|
|
648
|
+
if latest_file is None:
|
|
649
|
+
print_error("Could not determine latest mod file.")
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
binary_url, filename = extract_download_info(latest_file)
|
|
653
|
+
if not binary_url:
|
|
654
|
+
print_error("No valid download URL found in mod file.")
|
|
655
|
+
continue
|
|
656
|
+
|
|
657
|
+
print_info(f"Latest file: {filename}")
|
|
658
|
+
ok = download_file(binary_url, filename)
|
|
659
|
+
if ok:
|
|
660
|
+
print_info(f"Saved as: {filename}")
|
|
661
|
+
# Optional JSON export
|
|
662
|
+
try:
|
|
663
|
+
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
|
664
|
+
info_path = os.path.join(DOWNLOAD_DIR, "modinfo.json")
|
|
665
|
+
info = {
|
|
666
|
+
"game_name": game_name,
|
|
667
|
+
"mod_name": mod_name,
|
|
668
|
+
"mod_id": mod_id,
|
|
669
|
+
"file_id": latest_file.get("id") if isinstance(latest_file, dict) else None,
|
|
670
|
+
"date_downloaded": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
671
|
+
}
|
|
672
|
+
with open(info_path, "w", encoding="utf-8") as f:
|
|
673
|
+
json.dump(info, f, indent=2)
|
|
674
|
+
except Exception as exc:
|
|
675
|
+
print_error(f"Failed to write modinfo.json: {exc}")
|
|
676
|
+
else:
|
|
677
|
+
print_error("Download failed after retry.")
|
|
678
|
+
|
|
679
|
+
# Ask if user wants another
|
|
680
|
+
again = input("Download another mod? (y/n): ").strip().lower()
|
|
681
|
+
if again != "y":
|
|
682
|
+
print_info("Thanks for using ModioDirect.")
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def maybe_pause_on_exit():
|
|
687
|
+
# Help Windows users who double-click the .py (console closes immediately).
|
|
688
|
+
if os.name != "nt":
|
|
689
|
+
return
|
|
690
|
+
if "--no-pause" in sys.argv:
|
|
691
|
+
return
|
|
692
|
+
# Only pause for direct script runs without extra args.
|
|
693
|
+
if len(sys.argv) > 1:
|
|
694
|
+
return
|
|
695
|
+
try:
|
|
696
|
+
input("Press Enter to exit...")
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
if __name__ == "__main__":
|
|
702
|
+
try:
|
|
703
|
+
main()
|
|
704
|
+
except KeyboardInterrupt:
|
|
705
|
+
print_error("Interrupted by user.")
|
|
706
|
+
except Exception as exc:
|
|
707
|
+
print_error(f"Unexpected error: {exc}")
|
|
708
|
+
finally:
|
|
709
|
+
maybe_pause_on_exit()
|
|
710
|
+
|
|
711
|
+
"""
|
|
712
|
+
Simulated full run (example):
|
|
713
|
+
|
|
714
|
+
ModioDirect - mod.io downloader
|
|
715
|
+
Enter your mod.io API key: 12345INVALID
|
|
716
|
+
[Error] Invalid API key (401 Unauthorized).
|
|
717
|
+
Enter your mod.io API key: 67890VALID
|
|
718
|
+
[Info] API key validated.
|
|
719
|
+
Enter mod.io mod URL: https://mod.io/g/spaceengineers/m/assault-weapons-pack1
|
|
720
|
+
[Info] Game : Space Engineers
|
|
721
|
+
[Info] Mod : Assault Weapons Pack
|
|
722
|
+
[Info] Latest file: assault_weapons_pack1.zip
|
|
723
|
+
Downloading... 5%
|
|
724
|
+
Downloading... 10%
|
|
725
|
+
...
|
|
726
|
+
Downloading... 100%
|
|
727
|
+
[Info] Saved as: assault_weapons_pack1.zip
|
|
728
|
+
Download another mod? (y/n): n
|
|
729
|
+
[Info] Exiting.
|
|
730
|
+
|
|
731
|
+
"""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modiodirect
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Crash-proof direct downloader for mod.io (official API only)
|
|
5
|
+
Author: TheRootExec
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: mod.io,mods,downloader,cli
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: tqdm
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# ModioDirect v1.0.0  
|
|
25
|
+
|
|
26
|
+
**ModioDirect is a lightweight, single-file CLI that reliably downloads mods straight from mod.io via the official API—safe, fast, and free for use. It also allows manual downloads using an API key, bypassing the official game client.
|
|
27
|
+
Supports games like Space Engineers, SnowRunner, and more.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
- Official mod.io API only
|
|
36
|
+
- Validates API key
|
|
37
|
+
- Supports real mod.io URLs
|
|
38
|
+
- Fallback search when slugs fail
|
|
39
|
+
- Safe downloads with retries
|
|
40
|
+
- Progress bar (tqdm optional)
|
|
41
|
+
- Works on Windows & Linux
|
|
42
|
+
- Optional `--no-config` for shared machines
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
- Python 3.9+
|
|
46
|
+
- `pip install requests tqdm`
|
|
47
|
+
|
|
48
|
+
## How To Use It
|
|
49
|
+
```bash
|
|
50
|
+
python modiodirect.py OR double click the modiodirect.py
|
|
51
|
+
```
|
|
52
|
+
## Add Your Mod.io API Key E.g
|
|
53
|
+
```bash
|
|
54
|
+
0923d9369664ba08bd91c67.........
|
|
55
|
+
```
|
|
56
|
+
(optional) To avoid saving the API key to `config.json`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python modiodirect.py --no-config
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Paste a URL like:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
https://mod.io/g/GAME/m/example-mod
|
|
66
|
+
```
|
|
67
|
+
## :exclamation: Security Notice:
|
|
68
|
+
Your mod.io API key is private. Never share it or post it publicly.
|
|
69
|
+
ModioDirect stores the key locally and only uses it to communicate
|
|
70
|
+
with the official mod.io API.
|
|
71
|
+
|
|
72
|
+
# *SIMPLY WALKTHROUGH*:
|
|
73
|
+
<img width="1310" height="332" alt="Screenshot 2026-02-06 162920" src="https://github.com/user-attachments/assets/871142df-72c3-42b2-9655-f25d2b956488" />
|
|
74
|
+
<img width="1094" height="368" alt="Screenshot 2026-02-06 164436" src="https://github.com/user-attachments/assets/f351d3f7-8bc0-46b5-8c8b-1fe03af22332" />
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
## Why this exists
|
|
78
|
+
|
|
79
|
+
Most mod.io download tools are broken, outdated, or unsafe—and frankly, frustrating to use. ModioDirect was made to fix that: simple, reliable, and safe. It gives you full control, letting you manually download mods with an API key and even bypass the official game client when needed. No clutter, no crashes, just the mods you want, when you want them.
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
## Legal
|
|
83
|
+
This tool uses the official mod.io API. Users are responsible for complying with mod.io's Terms of Service.
|
|
84
|
+
This tool is not affiliated with, endorsed by, or officially supported by mod.io. Use at your own risk.
|
|
85
|
+
|
|
86
|
+
## 🔴Access limitations (important)🔴
|
|
87
|
+
|
|
88
|
+
Some games/mods are private, unlisted, or require OAuth access. In those cases, the mod.io API returns 404 even if the URL exists. This is an access restriction, not a bug in ModioDirect.
|
|
89
|
+
|
|
90
|
+
If you see:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
[Error] Game not accessible (404). The game may be private, unpublished, or require OAuth access.
|
|
94
|
+
```
|
|
95
|
+
<img width="1093" height="309" alt="Screenshot 2026-02-06 170047" src="https://github.com/user-attachments/assets/eb1148df-ef85-468f-a21b-d99cd26901db" />
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
API Key Limitations
|
|
99
|
+
Use a public game or mod to verify that your API key is working.
|
|
100
|
+
API keys can only access publicly available content.
|
|
101
|
+
Private or unlisted mods are not accessible using API keys alone, as they require OAuth-based authentication.
|
|
102
|
+
OAuth support is not currently implemented in ModioDirect. Future updates may add OAuth support if permitted by mod.io’s policies.
|
|
103
|
+
|
|
104
|
+
## Upcoming Features Updates
|
|
105
|
+
ModioDirect is actively maintained. The following features are planned for future releases:
|
|
106
|
+
- Batch mod downloads
|
|
107
|
+
Download multiple mods in one run using a text file containing mod.io URLs.
|
|
108
|
+
- Optional auto-install to game mod folders
|
|
109
|
+
Detect common game mod directories (opt-in only, user confirmation required).
|
|
110
|
+
- Windows standalone executable (.exe)
|
|
111
|
+
A portable build for Windows users that does not require Python.
|
|
112
|
+
- PyPI package distribution
|
|
113
|
+
Install ModioDirect using pip install modiodirect and run it as a system command.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
## 🌟 Special Thanks
|
|
117
|
+
|
|
118
|
+
Thanks to [@Diversion-CTF](https://github.com/Diversion-CTF) For helping with the logo
|
|
119
|
+
|
|
120
|
+
## 🤝 Contributions and feature requests are welcome
|
|
121
|
+
Please open an issue to discuss your ideas or suggestions.
|
|
122
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# ModioDirect v1.0.0  
|
|
2
|
+
|
|
3
|
+
**ModioDirect is a lightweight, single-file CLI that reliably downloads mods straight from mod.io via the official API—safe, fast, and free for use. It also allows manual downloads using an API key, bypassing the official game client.
|
|
4
|
+
Supports games like Space Engineers, SnowRunner, and more.
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
- Official mod.io API only
|
|
13
|
+
- Validates API key
|
|
14
|
+
- Supports real mod.io URLs
|
|
15
|
+
- Fallback search when slugs fail
|
|
16
|
+
- Safe downloads with retries
|
|
17
|
+
- Progress bar (tqdm optional)
|
|
18
|
+
- Works on Windows & Linux
|
|
19
|
+
- Optional `--no-config` for shared machines
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
- Python 3.9+
|
|
23
|
+
- `pip install requests tqdm`
|
|
24
|
+
|
|
25
|
+
## How To Use It
|
|
26
|
+
```bash
|
|
27
|
+
python modiodirect.py OR double click the modiodirect.py
|
|
28
|
+
```
|
|
29
|
+
## Add Your Mod.io API Key E.g
|
|
30
|
+
```bash
|
|
31
|
+
0923d9369664ba08bd91c67.........
|
|
32
|
+
```
|
|
33
|
+
(optional) To avoid saving the API key to `config.json`:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python modiodirect.py --no-config
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Paste a URL like:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
https://mod.io/g/GAME/m/example-mod
|
|
43
|
+
```
|
|
44
|
+
## :exclamation: Security Notice:
|
|
45
|
+
Your mod.io API key is private. Never share it or post it publicly.
|
|
46
|
+
ModioDirect stores the key locally and only uses it to communicate
|
|
47
|
+
with the official mod.io API.
|
|
48
|
+
|
|
49
|
+
# *SIMPLY WALKTHROUGH*:
|
|
50
|
+
<img width="1310" height="332" alt="Screenshot 2026-02-06 162920" src="https://github.com/user-attachments/assets/871142df-72c3-42b2-9655-f25d2b956488" />
|
|
51
|
+
<img width="1094" height="368" alt="Screenshot 2026-02-06 164436" src="https://github.com/user-attachments/assets/f351d3f7-8bc0-46b5-8c8b-1fe03af22332" />
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## Why this exists
|
|
55
|
+
|
|
56
|
+
Most mod.io download tools are broken, outdated, or unsafe—and frankly, frustrating to use. ModioDirect was made to fix that: simple, reliable, and safe. It gives you full control, letting you manually download mods with an API key and even bypass the official game client when needed. No clutter, no crashes, just the mods you want, when you want them.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Legal
|
|
60
|
+
This tool uses the official mod.io API. Users are responsible for complying with mod.io's Terms of Service.
|
|
61
|
+
This tool is not affiliated with, endorsed by, or officially supported by mod.io. Use at your own risk.
|
|
62
|
+
|
|
63
|
+
## 🔴Access limitations (important)🔴
|
|
64
|
+
|
|
65
|
+
Some games/mods are private, unlisted, or require OAuth access. In those cases, the mod.io API returns 404 even if the URL exists. This is an access restriction, not a bug in ModioDirect.
|
|
66
|
+
|
|
67
|
+
If you see:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
[Error] Game not accessible (404). The game may be private, unpublished, or require OAuth access.
|
|
71
|
+
```
|
|
72
|
+
<img width="1093" height="309" alt="Screenshot 2026-02-06 170047" src="https://github.com/user-attachments/assets/eb1148df-ef85-468f-a21b-d99cd26901db" />
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
API Key Limitations
|
|
76
|
+
Use a public game or mod to verify that your API key is working.
|
|
77
|
+
API keys can only access publicly available content.
|
|
78
|
+
Private or unlisted mods are not accessible using API keys alone, as they require OAuth-based authentication.
|
|
79
|
+
OAuth support is not currently implemented in ModioDirect. Future updates may add OAuth support if permitted by mod.io’s policies.
|
|
80
|
+
|
|
81
|
+
## Upcoming Features Updates
|
|
82
|
+
ModioDirect is actively maintained. The following features are planned for future releases:
|
|
83
|
+
- Batch mod downloads
|
|
84
|
+
Download multiple mods in one run using a text file containing mod.io URLs.
|
|
85
|
+
- Optional auto-install to game mod folders
|
|
86
|
+
Detect common game mod directories (opt-in only, user confirmation required).
|
|
87
|
+
- Windows standalone executable (.exe)
|
|
88
|
+
A portable build for Windows users that does not require Python.
|
|
89
|
+
- PyPI package distribution
|
|
90
|
+
Install ModioDirect using pip install modiodirect and run it as a system command.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## 🌟 Special Thanks
|
|
94
|
+
|
|
95
|
+
Thanks to [@Diversion-CTF](https://github.com/Diversion-CTF) For helping with the logo
|
|
96
|
+
|
|
97
|
+
## 🤝 Contributions and feature requests are welcome
|
|
98
|
+
Please open an issue to discuss your ideas or suggestions.
|
|
99
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modiodirect
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Crash-proof direct downloader for mod.io (official API only)
|
|
5
|
+
Author: TheRootExec
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: mod.io,mods,downloader,cli
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: tqdm
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# ModioDirect v1.0.0  
|
|
25
|
+
|
|
26
|
+
**ModioDirect is a lightweight, single-file CLI that reliably downloads mods straight from mod.io via the official API—safe, fast, and free for use. It also allows manual downloads using an API key, bypassing the official game client.
|
|
27
|
+
Supports games like Space Engineers, SnowRunner, and more.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
- Official mod.io API only
|
|
36
|
+
- Validates API key
|
|
37
|
+
- Supports real mod.io URLs
|
|
38
|
+
- Fallback search when slugs fail
|
|
39
|
+
- Safe downloads with retries
|
|
40
|
+
- Progress bar (tqdm optional)
|
|
41
|
+
- Works on Windows & Linux
|
|
42
|
+
- Optional `--no-config` for shared machines
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
- Python 3.9+
|
|
46
|
+
- `pip install requests tqdm`
|
|
47
|
+
|
|
48
|
+
## How To Use It
|
|
49
|
+
```bash
|
|
50
|
+
python modiodirect.py OR double click the modiodirect.py
|
|
51
|
+
```
|
|
52
|
+
## Add Your Mod.io API Key E.g
|
|
53
|
+
```bash
|
|
54
|
+
0923d9369664ba08bd91c67.........
|
|
55
|
+
```
|
|
56
|
+
(optional) To avoid saving the API key to `config.json`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python modiodirect.py --no-config
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Paste a URL like:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
https://mod.io/g/GAME/m/example-mod
|
|
66
|
+
```
|
|
67
|
+
## :exclamation: Security Notice:
|
|
68
|
+
Your mod.io API key is private. Never share it or post it publicly.
|
|
69
|
+
ModioDirect stores the key locally and only uses it to communicate
|
|
70
|
+
with the official mod.io API.
|
|
71
|
+
|
|
72
|
+
# *SIMPLY WALKTHROUGH*:
|
|
73
|
+
<img width="1310" height="332" alt="Screenshot 2026-02-06 162920" src="https://github.com/user-attachments/assets/871142df-72c3-42b2-9655-f25d2b956488" />
|
|
74
|
+
<img width="1094" height="368" alt="Screenshot 2026-02-06 164436" src="https://github.com/user-attachments/assets/f351d3f7-8bc0-46b5-8c8b-1fe03af22332" />
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
## Why this exists
|
|
78
|
+
|
|
79
|
+
Most mod.io download tools are broken, outdated, or unsafe—and frankly, frustrating to use. ModioDirect was made to fix that: simple, reliable, and safe. It gives you full control, letting you manually download mods with an API key and even bypass the official game client when needed. No clutter, no crashes, just the mods you want, when you want them.
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
## Legal
|
|
83
|
+
This tool uses the official mod.io API. Users are responsible for complying with mod.io's Terms of Service.
|
|
84
|
+
This tool is not affiliated with, endorsed by, or officially supported by mod.io. Use at your own risk.
|
|
85
|
+
|
|
86
|
+
## 🔴Access limitations (important)🔴
|
|
87
|
+
|
|
88
|
+
Some games/mods are private, unlisted, or require OAuth access. In those cases, the mod.io API returns 404 even if the URL exists. This is an access restriction, not a bug in ModioDirect.
|
|
89
|
+
|
|
90
|
+
If you see:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
[Error] Game not accessible (404). The game may be private, unpublished, or require OAuth access.
|
|
94
|
+
```
|
|
95
|
+
<img width="1093" height="309" alt="Screenshot 2026-02-06 170047" src="https://github.com/user-attachments/assets/eb1148df-ef85-468f-a21b-d99cd26901db" />
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
API Key Limitations
|
|
99
|
+
Use a public game or mod to verify that your API key is working.
|
|
100
|
+
API keys can only access publicly available content.
|
|
101
|
+
Private or unlisted mods are not accessible using API keys alone, as they require OAuth-based authentication.
|
|
102
|
+
OAuth support is not currently implemented in ModioDirect. Future updates may add OAuth support if permitted by mod.io’s policies.
|
|
103
|
+
|
|
104
|
+
## Upcoming Features Updates
|
|
105
|
+
ModioDirect is actively maintained. The following features are planned for future releases:
|
|
106
|
+
- Batch mod downloads
|
|
107
|
+
Download multiple mods in one run using a text file containing mod.io URLs.
|
|
108
|
+
- Optional auto-install to game mod folders
|
|
109
|
+
Detect common game mod directories (opt-in only, user confirmation required).
|
|
110
|
+
- Windows standalone executable (.exe)
|
|
111
|
+
A portable build for Windows users that does not require Python.
|
|
112
|
+
- PyPI package distribution
|
|
113
|
+
Install ModioDirect using pip install modiodirect and run it as a system command.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
## 🌟 Special Thanks
|
|
117
|
+
|
|
118
|
+
Thanks to [@Diversion-CTF](https://github.com/Diversion-CTF) For helping with the logo
|
|
119
|
+
|
|
120
|
+
## 🤝 Contributions and feature requests are welcome
|
|
121
|
+
Please open an issue to discuss your ideas or suggestions.
|
|
122
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
ModioDirect.py
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
modiodirect.egg-info/PKG-INFO
|
|
7
|
+
modiodirect.egg-info/SOURCES.txt
|
|
8
|
+
modiodirect.egg-info/dependency_links.txt
|
|
9
|
+
modiodirect.egg-info/entry_points.txt
|
|
10
|
+
modiodirect.egg-info/requires.txt
|
|
11
|
+
modiodirect.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ModioDirect
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "modiodirect"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Crash-proof direct downloader for mod.io (official API only)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "TheRootExec"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mod.io", "mods", "downloader", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Operating System :: Microsoft :: Windows",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12"
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests",
|
|
29
|
+
"tqdm"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
modiodirect = "ModioDirect:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
py-modules = ["ModioDirect"]
|
|
37
|
+
include-package-data = true
|