Skip to content

Instantly share code, notes, and snippets.

@maalrron
Last active January 13, 2025 17:19
Show Gist options
  • Save maalrron/877b2edb23cc5d99d6a6b4c22f708e58 to your computer and use it in GitHub Desktop.
Save maalrron/877b2edb23cc5d99d6a6b4c22f708e58 to your computer and use it in GitHub Desktop.
download megascans purchased assets

This code is a modification of quixel.py by ad044.

The key change is that it allows you to download your purchased assets by category and sub categories instead of all the assets. It will show the number of assets it will download and ask for confirmation.

I recommend running the same script twice to make sure no asset failed to download. if it says 0, then you can change the category.

Please avoid downloading everything in one go to avoid stress on the servers.

If someone ends up downloading everything, please tell us in the comments the total disk space it takes.

How to use

  1. copy dlMegascans.py and save it on disk.
  2. copy (preferably download) the very very long ms_asset_categories.json found here (to not make this page unviewable): "https://gist.github.com/maalrron/3ebe6514f8fba184311aa63fb68f841d"
  3. Get your authentication token from megascan:
  • Login into https://quixel.com
  • copy the script from below (gettoken.js)
  • Open devtools (F12) -> Go to "Console" tab
  • Paste in the script and press Enter. (if it doesn't let you paste, on firefox: https://www.youtube.com/watch?v=ekN2i953Nas at 01:14 / on chrome, type “allow pasting” then ENTER first.) if it returns "undefined", disable your add blocker.
  • copy the token, it's the very long line (or multiple lines) of digits and characters.
  • paste it in the token line of the script dlMegascans.py (between quotes).
  1. Set the path in the "download_path" line, e.g: "/home/Downloads".
  2. Set the path in the "json_file_path" line. e.g: "/home/Downloads/ms_asset_categories.json".
  3. Set the path where you want the cache.txt file to be created.
  4. set the target_category to the category and sub categories you want separated with /. one category or sub category at a time:
  • It works by matching every word with the categories of each asset in the json file.
  • The order doesn't matter but they need to be separated with /.
  • It's not case sensitive and ignores any last "s" so "3d plant" will also download what's in "3D Plants"

Notes:

  • Like in the original code, it creates a cache.txt file (in the same location as the script, or wherever you're cd at in the terminal) to avoid downloading the same assets multiple times.

  • find this line: with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: max_workers is the number of file downloadable at the same time. 10 is a good compromise between speed and stress on the network/server. Higher and you might get failed downloads. Lower it if your network can't handle 10 but I don't recommend going higher (and definitely not higher than 20).

  • If someone can figure out how to set the resolution to download, (lowest, mid, highest), I would love to have this feature.

Disclaimer: I'm not a programmer, I'm a chatGPT steerer. The code was only lightly tested but seems to work fine

Debug

  • If you get {'statusCode': 401, 'error': 'Unauthorized', 'message': 'Expired token', 'attributes': {'error': 'Expired token'}} simply do step 3 again. This shouldn't happen if you're running the script shortly after setting it up. I don't know how often the toke changes but it's counted in hours so no rush.

  • If you get an error: Failed to download asset @@@, status code: 502 Response: 502 Bad Gateway, simply wait for the other assets to finish downloading and run the script again.

import os
import json
import concurrent.futures
import requests
import time
# Define the token, download path, and target category
token = "paste token here"
download_path = "path/to/where/you/want/the/zips/to/go" # Update with the correct path to the directory
json_file_path = "path/to/json/ms_asset_categories.json" # Update with the correct path to the file, not the directory
cache_file_path = "path/to/cache.txt"
target_category = "3D plants/grass/lawn"
# Function to normalize category strings
def normalize_category(category):
return category.strip().lower().rstrip('s')
# Function to load asset categories from JSON file
def load_asset_categories(json_file):
with open(json_file, 'r') as f:
return json.load(f)
# Function to convert categories to a space-separated string
def categories_to_string(categories):
result = []
if isinstance(categories, dict):
for key, value in categories.items():
if isinstance(value, dict) and value:
subcategories = categories_to_string(value)
if subcategories:
result.append(f"{key} {subcategories}")
else:
result.append(key)
elif isinstance(value, list):
for item in value:
result.append(normalize_category(item))
else:
result.append(normalize_category(key))
return " ".join(result)
# Load the asset categories from the JSON file
asset_categories_dict = load_asset_categories(json_file_path)
# Print what it's trying to match against
print(f"Trying to match against target category: {target_category}")
def get_asset_download_id(asset_id):
url = "https://quixel.com/v1/downloads"
params = {
"asset": asset_id,
"config": {
"lowerlod_meshes" : True,
"maxlod" : 100000000,
"highpoly": False,
"ztool": False,
"lowerlod_normals": True,
"albedo_lods": True,
"meshMimeType": "application/x-fbx",
"brushes": False,
"lods": [29155, 13541, 6289, 2921],
},
}
headers = {
"X-Api-Key": "your_api_key_here",
"Authorization": "Bearer " + token,
}
print(f"Getting download ID for asset: {asset_id}")
download_url_response = requests.post(url, headers=headers, json=params)
if download_url_response.status_code == 200:
return download_url_response.json()["id"]
else:
print(f"Failed to get asset download url for id: {asset_id}")
print(download_url_response.json())
return None
def download_asset(download_id, download_directory):
os.makedirs(download_directory, exist_ok=True)
url = f"https://assetdownloads.quixel.com/download/{download_id}?preserveStructure=true&url=https%3A%2F%2Fquixel.com%2Fv1%2Fdownloads"
print(f"Attempting to download from: {url}")
response = requests.get(url, stream=True)
if response.status_code == 400:
print(f"Error 400: {response.text}") # Print the response to see what's causing the issue
attempt_count = 0
delay = 5
max_attempts = 5
while attempt_count < max_attempts:
response = requests.get(url, stream=True)
if response.status_code == 200:
content_disposition = response.headers.get("Content-Disposition")
filename = content_disposition.split("filename=")[-1].strip('"') if content_disposition else download_id
file_path = os.path.join(download_directory, filename)
print(f"Downloading file: {file_path}")
try:
with open(file_path, "wb") as file:
for chunk in response.iter_content(chunk_size=8192):
file.write(chunk)
print(f"File downloaded successfully: {file_path}")
return True
except requests.exceptions.ChunkedEncodingError as e:
print(f"Error during download: {e}")
time.sleep(delay)
delay += 5
attempt_count += 1
else:
print(f"Failed to download asset {download_id}, status code: {response.status_code}")
print(f"Response: {response.text}") # Print the response content for more details
return False
print(f"Exceeded maximum retry attempts for asset {download_id}")
return False
# Load cached assets
cached_assets = set()
if os.path.exists(cache_file_path):
with open(cache_file_path, "r") as cache_file:
cached_assets = set(cache_file.read().splitlines())
# Normalize target category for matching
normalized_target_categories = [normalize_category(part) for part in target_category.split("/")]
matching_asset_ids = []
# Check matches for each asset in the loaded categories
for asset_id, categories in asset_categories_dict.items():
# Convert the categories to a single string for matching
categories_str = categories_to_string(categories)
# Check if all parts of target_category exist in the categories string
matches = all(normalize_category(part) in categories_str.lower() for part in normalized_target_categories)
if matches and asset_id not in cached_assets:
print(f"Asset ID: {asset_id} matches target category: {target_category}")
matching_asset_ids.append(asset_id)
if not matching_asset_ids:
print("No assets found for the target category.")
exit()
# Ask the user for confirmation before downloading
print(f"{len(matching_asset_ids)} assets found.")
confirmation = input(f"Do you want to download these {len(matching_asset_ids)} assets? (y/n): ").strip().lower()
if confirmation != "y":
print("Download canceled.")
exit()
# Function to handle downloading for threading
def download_asset_with_id(asset_id):
download_id = get_asset_download_id(asset_id)
if download_id:
return download_asset(download_id, download_path)
# Open the cache file for appending new downloads
with open(cache_file_path, "a+") as cache_file:
# Use threading for faster downloading
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(download_asset_with_id, asset_id): asset_id for asset_id in matching_asset_ids}
for future in concurrent.futures.as_completed(futures):
asset_id = futures[future]
try:
result = future.result()
if result:
# Add the asset to the cache file after successful download
cache_file.write(f"{asset_id}\n")
cache_file.flush()
except Exception as e:
print(f"Error downloading asset {asset_id}: {e}")
// Function to get the value of a specific cookie by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// Get the auth cookie
const authCookie = getCookie('auth');
// Parse the auth cookie (it should be a JSON string containing the token)
if (authCookie) {
try {
const authData = JSON.parse(decodeURIComponent(authCookie));
const authToken = authData.token;
console.log("Auth Token:", authToken);
} catch (error) {
console.error("Error parsing auth cookie:", error);
}
} else {
console.error("Auth cookie not found. Please make sure you are logged in.");
}
@WAUthethird
Copy link

Ah, the issue there is that I threw together the category download feature pretty quickly, so it doesn't actually check your input for validity

You need to type it exactly as printed, so type "3D asset", not "3d asset"

@SkillipEvolver
Copy link

Thank you @WAUthethird. I tried again, case-sensitive, but had the same result. It now occurs to me that this might be related to an earlier attempt to download the 3D assets group but using a different script (not immediately sure whose, think it was @PolyShifter or maybe @maalrron). That group download did complete, and also created the folder tree; all asset zips were downloaded, but the zip files were all (bar 3x) corrupt and do not open. (that's when I thought I'd try your script).
I'm still thinking there must be a file somewhere on my system that says the asset group has been downloaded already, and that that file is interfering with your script. Any hints on what I should look for please, or any other ideas?
PS - @PolyShifter - if you should see this thread, do you have any ideas why the zips were corrupted? - Thanks

@WAUthethird
Copy link

@SkillipEvolver The only way it knows what assets have already been downloaded is via the checksums.json file that it creates. Check that, if it exists. Otherwise, check the metadata file itself, it might be incomplete or broken in some way.

@SkillipEvolver
Copy link

Thanks @WAUthethird. Not sure why I had the wrong checksums.json, but that was the culprit.
It would still be nice to create the assets' folder tree if you agree & have the time to write something - I'm sure many others would appreciate it as well.
Other than that, all sorted now, thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment