import os
import zipfile
import re
from datetime import datetime
from packaging import version # pip install packaging
import platform
import shutil
# --------------------------
# Terminal Colors
# --------------------------
class Colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
ORANGE = '\033[33m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
def color_print(msg, color):
print(f"{color}{msg}{Colors.ENDC}")
# --------------------------
# Utilities
# --------------------------
def timestamp():
return datetime.now().strftime("%d-%m-%Y (%I-%M%p)")
def get_desktop_path():
system = platform.system()
if system == "Windows":
return os.path.join(os.path.expanduser("~"), "Desktop")
elif system in ["Linux", "Darwin"]: # Linux or Mac
return os.path.join(os.path.expanduser("~"), "Desktop")
else:
return os.getcwd()
def get_downloads_path():
system = platform.system()
if system == "Windows":
return os.path.join(os.path.expanduser("~"), "Downloads")
else:
return get_desktop_path()
# --------------------------
# Get User Inputs
# --------------------------
print("Welcome to the Minecraft Mod Scanner!")
# Recursive Search Option
print("Do you want a recursive search? 1=Yes 2=No")
recursive_input = input("> ")
recursive = recursive_input.strip() == "1"
input("Press ENTER to confirm and continue...")
# Game version
game_version = input("Enter your Minecraft game version (e.g., 1.21.1): ").strip()
# Loader choice
print("What loader are you using? 1=NEOForge 2=Forge 3=Fabric")
loader_choice = input("> ").strip()
loader_choice = loader_choice if loader_choice in ["1","2","3"] else "1"
# Root folder
root_folder = os.getcwd()
# Output root
output_root = os.path.join(get_downloads_path(), "Duplicates Found")
os.makedirs(output_root, exist_ok=True)
# --------------------------
# File scanning
# --------------------------
mods_info = {} # mod_name -> list of dict {filename, path, mc_version, mod_version, loader}
for dirpath, dirnames, filenames in os.walk(root_folder):
for file in filenames:
if file.lower().endswith(".jar"):
file_path = os.path.join(dirpath, file)
mc_ver, mod_ver, mod_name, loader = "Unknown", "Unknown", os.path.splitext(file)[0], "Unknown"
# Attempt to read metadata inside the jar
try:
with zipfile.ZipFile(file_path, 'r') as jar:
# Check mods.toml (NeoForge / Forge)
if "META-INF/mods.toml" in jar.namelist():
content = jar.read("META-INF/mods.toml").decode(errors='ignore')
name_match = re.search(r'name\s*=\s*"(.*?)"', content)
mc_match = re.search(r'minecraft\s*=\s*"(.*?)"', content)
ver_match = re.search(r'version\s*=\s*"(.*?)"', content)
if name_match:
mod_name = name_match.group(1)
if mc_match:
mc_ver = mc_match.group(1)
if ver_match:
mod_ver = ver_match.group(1)
loader = "NEOForge"
# Optionally check fabric.mod.json here if needed
except Exception as e:
pass
# Store info
mods_info.setdefault(mod_name.lower(), []).append({
"filename": file,
"path": file_path,
"mc_version": mc_ver,
"mod_version": mod_ver,
"loader": loader
})
if not recursive:
break # Don't recurse
# --------------------------
# Game version & loader mismatch
# --------------------------
game_mismatch_folder = os.path.join(output_root, "Game Mismatch Files")
os.makedirs(game_mismatch_folder, exist_ok=True)
for mod_name, entries in mods_info.items():
for e in entries:
if e['mc_version'] != game_version or (loader_choice=="1" and e['loader']!="NEOForge") \
or (loader_choice=="2" and e['loader']!="Forge") or (loader_choice=="3" and e['loader']!="Fabric"):
dest = os.path.join(game_mismatch_folder, e['filename'])
shutil.copy2(e['path'], dest) # copy, don't move
color_print(f"[Game Mismatch] {e['filename']} -> {game_mismatch_folder}", Colors.WARNING)
# --------------------------
# Metadata Missing
# --------------------------
metadata_missing_folder = os.path.join(output_root, "Metadata Missing")
os.makedirs(metadata_missing_folder, exist_ok=True)
metadata_missing_file = os.path.join(metadata_missing_folder, f"Metadata Missing Mods - {timestamp()}.txt")
with open(metadata_missing_file, 'w', encoding='utf-8') as f:
for mod_name, entries in mods_info.items():
for e in entries:
if e['mc_version']=="Unknown" or e['mod_version']=="Unknown":
f.write(e['filename']+"\n")
color_print(f"Metadata missing mods saved to {metadata_missing_file}", Colors.ORANGE)
# --------------------------
# Duplicates & Multi-version
# --------------------------
multi_version_folder = os.path.join(output_root, "Detected Multi-Version")
os.makedirs(multi_version_folder, exist_ok=True)
multi_version_file = os.path.join(multi_version_folder, f"Detection List - {timestamp()}.txt")
with open(multi_version_file, 'w', encoding='utf-8') as f:
for mod_name, entries in mods_info.items():
if len(entries)>1:
f.write(f"{mod_name}:\n")
for e in entries:
f.write(f" - {e['filename']} [MC:{e['mc_version']}, MOD:{e['mod_version']}]\n")
color_print(f"Multi-version mods saved to {multi_version_file}", Colors.ORANGE)
# --------------------------
# NeoForge dependency conflicts
# --------------------------
def parse_version_range(range_str):
match = re.match(r'\[([\d\.]+),?\)?', range_str)
if match:
return match.group(1)
return None
def check_neoforge_conflicts(mods_info):
conflicts = []
for mod_name, entries in mods_info.items():
for e in entries:
try:
with zipfile.ZipFile(e['path'], 'r') as jar:
if "META-INF/mods.toml" in jar.namelist():
content = jar.read("META-INF/mods.toml").decode(errors='ignore')
dep_blocks = re.findall(r'\[\[dependencies\..*?\]\](.*?)\n\n', content, re.DOTALL)
for dep_block in dep_blocks:
dep_id_match = re.search(r'modId\s*=\s*"(.*?)"', dep_block)
dep_ver_match = re.search(r'versionRange\s*=\s*"(.*?)"', dep_block)
if dep_id_match and dep_ver_match:
dep_id = dep_id_match.group(1).lower()
dep_ver_range = dep_ver_match.group(1)
min_version = parse_version_range(dep_ver_range)
installed = mods_info.get(dep_id, [])
if installed:
satisfied = False
for ins in installed:
try:
if version.parse(ins['mod_version']) >= version.parse(min_version):
satisfied = True
break
except:
continue
if not satisfied:
conflicts.append({
"mod": e['filename'],
"dependency": dep_id,
"required": dep_ver_range,
"installed": [i['mod_version'] for i in installed]
})
else:
conflicts.append({
"mod": e['filename'],
"dependency": dep_id,
"required": dep_ver_range,
"installed": []
})
except:
continue
return conflicts
neo_conflicts = check_neoforge_conflicts(mods_info)
if neo_conflicts:
conflicts_folder = os.path.join(output_root, "Conflicts")
os.makedirs(conflicts_folder, exist_ok=True)
path_conf = os.path.join(conflicts_folder, f"Conflicts - {timestamp()}.txt")
with open(path_conf, 'w', encoding='utf-8') as f:
for c in neo_conflicts:
f.write(f"{c['mod']} requires {c['dependency']} {c['required']}, installed versions: {c['installed']}\n")
color_print(f"NeoForge dependency conflicts logged to {path_conf}", Colors.ORANGE)
# --------------------------
# Terminal output capture
# --------------------------
terminal_folder = os.path.join(output_root, "Terminal Output")
os.makedirs(terminal_folder, exist_ok=True)
terminal_file = os.path.join(terminal_folder, f"Terminal Output - {timestamp()}.txt")
with open(terminal_file, 'w', encoding='utf-8') as f:
for mod_name, entries in mods_info.items():
f.write(f"{mod_name}:\n")
for e in entries:
f.write(f" - {e['filename']} [MC:{e['mc_version']}, MOD:{e['mod_version']}]\n")
color_print(f"Full terminal output saved to {terminal_file}", Colors.OKBLUE)
# --------------------------
# Keep terminal open with confirmations
# --------------------------
while True:
color_print("Do you want to close the terminal? 1=Yes 2=No", Colors.BOLD)
ans = input("> ")
if ans.strip()=="1":
color_print("Press ENTER twice to exit...", Colors.BOLD)
input()
input()
break
elif ans.strip()=="2":
color_print("Terminal will remain open.", Colors.OKGREEN)
break