added ModPacks support

This commit is contained in:
2026-08-04 20:23:32 -05:00
parent ef7147ca83
commit 50a70bfe31
4 changed files with 227 additions and 29 deletions
+114 -22
View File
@@ -506,17 +506,69 @@ def read_animation_manifest(path):
zf.close()
def _normalize_modloader(loader_id):
"""'fabricloader-0.16.9''fabric', 'neoforge-21.1.66''neoforge'."""
low = str(loader_id or '').lower().strip()
if not low:
return ''
for prefix, name in (
('fabricloader', 'fabric'),
('neoforge', 'neoforge'),
('quilt', 'quilt'),
('liteloader', 'liteloader'),
('forge', 'forge'),
('fml', 'forge'),
('rift', 'rift'),
('vanilla', 'vanilla'),
):
if low.startswith(prefix):
return name
return low.split('-')[0]
def _mod_env(env):
"""Normalize an mrpack env object ({client, server}) to a label."""
client = str((env or {}).get('client') or '')
server = str((env or {}).get('server') or '')
if 'optional' in (client, server):
return 'optional'
if client == 'required' and server == 'unsupported':
return 'client'
if server == 'required' and client == 'unsupported':
return 'server'
return ''
def _modpack_totals(files):
total = len(files)
if any('required' in f for f in files):
required = sum(1 for f in files if f.get('required'))
optional = total - required
elif any('env' in f for f in files):
optional = sum(1 for f in files if f.get('env') == 'optional')
required = total - optional
else:
required = optional = None
return {
'total': total,
'required': required,
'optional': optional,
'client': sum(1 for f in files if f.get('env') == 'client'),
'server': sum(1 for f in files if f.get('env') == 'server'),
}
def parse_mods_manifest(path, filename=''):
"""Inspect a modpack archive and build a manifest for the Mods tab.
- Modrinth .mrpack: modrinth.index.json → dependencies
- Curseforge modpack: manifest.json → files array
- Classic folder layout: minecraft/mods/*.jar
Returns a dict: {source, files: [...]}.
- Modrinth .mrpack: modrinth.index.json → per-file paths + env
- Curseforge modpack: manifest.json → MC version, loaders, file list
- Full instance / folder: mods under mods/, minecraft/mods/, .minecraft/mods/
Returns a dict: {source, minecraft, modloaders, files, totals}.
"""
zf = _open_zip(path)
if zf is None:
return {'source': 'unknown', 'files': []}
return _modpack_result('unknown', '', [], [])
try:
names = set(zf.namelist())
@@ -526,38 +578,78 @@ def parse_mods_manifest(path, filename=''):
index = json.loads(zf.read('modrinth.index.json').decode('utf-8', 'replace'))
except (KeyError, json.JSONDecodeError):
index = {}
deps = []
for dep in index.get('dependencies') or []:
deps.append({
'name': dep.get('file_name') or dep.get('project_id', 'unknown'),
'version_id': dep.get('version_id'),
'type': dep.get('dependency_type', ''),
files = []
for entry in index.get('files') or []:
path_name = str(entry.get('path') or '').replace('\\', '/')
if not path_name:
continue
files.append({
'name': path_name.rsplit('/', 1)[-1],
'file_name': path_name,
'env': _mod_env(entry.get('env')),
})
return {'source': 'modrinth', 'files': deps}
return _modpack_result('modrinth', '', [], files)
if 'manifest.json' in names:
try:
manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace'))
except (KeyError, json.JSONDecodeError):
manifest = {}
mc = manifest.get('minecraft') or {}
minecraft = str(mc.get('version') or '')
modloaders = []
for loader in mc.get('modLoaders') or []:
norm = _normalize_modloader(loader.get('id'))
if norm and norm not in modloaders:
modloaders.append(norm)
files = []
for entry in manifest.get('files') or []:
file_name = str(entry.get('fileName') or '')
slug = str(entry.get('projectSlug') or '')
project_id = entry.get('projectID')
pid = str(project_id) if project_id else ''
name = slug or file_name or (f'Mod #{pid}' if pid else 'unknown')
files.append({
'name': entry.get('fileName', entry.get('projectID', 'unknown')),
'project_id': entry.get('projectID'),
'file_id': entry.get('fileID'),
'name': name,
'file_name': file_name,
'required': bool(entry.get('required', True)),
'slug': slug,
'project_id': pid,
'url': f'https://www.curseforge.com/projects/{pid}' if pid else '',
})
if files:
return {'source': 'curseforge', 'files': files}
if files or minecraft or modloaders:
return _modpack_result('curseforge', minecraft, modloaders, files)
# Full instance / folder: mod jars under a mods/ dir at any nesting
# (minecraft/mods, .minecraft/mods, <instance>/minecraft/mods, mods).
mods = []
prefix = 'minecraft/mods/'
seen = set()
is_instance = False
for name in sorted(names):
if name.startswith(prefix) and not name.endswith('/'):
mods.append({'name': name[len(prefix):]})
if name.endswith('/') or not name.lower().endswith('.jar'):
continue
parts = name.split('/')
if 'mods' not in parts or parts.index('mods') >= len(parts) - 1:
continue
if 'minecraft' in parts or '.minecraft' in parts:
is_instance = True
fname = parts[-1]
if fname and fname not in seen:
seen.add(fname)
mods.append({'name': fname, 'file_name': name})
if mods:
return {'source': 'folder', 'files': mods}
return _modpack_result('instance' if is_instance else 'folder', '', [], mods)
return {'source': 'unknown', 'files': []}
return _modpack_result('unknown', '', [], [])
finally:
zf.close()
def _modpack_result(source, minecraft, modloaders, files):
return {
'source': source,
'minecraft': minecraft,
'modloaders': modloaders,
'files': files,
'totals': _modpack_totals(files),
}