working Mods Cat
This commit is contained in:
@@ -5,8 +5,10 @@ never re-reads large archives. All functions take the on-disk path of an
|
||||
adopted file (under MEDIA_ROOT) and return plain dicts/values.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
@@ -59,6 +61,170 @@ def read_skins_manifest(path):
|
||||
zf.close()
|
||||
|
||||
|
||||
def _open_zip_any(source):
|
||||
"""Open a zip from a path or raw bytes."""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
return zipfile.ZipFile(io.BytesIO(source))
|
||||
return _open_zip(source)
|
||||
|
||||
|
||||
def _mc_from_filename(filename):
|
||||
"""Best-effort MC version from a mod filename like 'x-1.0+mc26.2.jar'."""
|
||||
m = re.search(r'mc(\d+(?:\.\d+)*)', str(filename).lower())
|
||||
return m.group(1) if m else ''
|
||||
|
||||
|
||||
def _manifest_value(raw, key):
|
||||
for line in raw.splitlines():
|
||||
if line.startswith(key + ':'):
|
||||
return line.split(':', 1)[1].strip()
|
||||
return ''
|
||||
|
||||
|
||||
def read_mod_manifest(source):
|
||||
"""Parse a single Minecraft mod jar (path or bytes) into metadata or None.
|
||||
|
||||
Loader detection order: fabric.mod.json → quilt.mod.json →
|
||||
META-INF/neoforge.mods.toml → META-INF/mods.toml → META-INF/MANIFEST.MF.
|
||||
"""
|
||||
zf = _open_zip_any(source)
|
||||
if zf is None:
|
||||
return None
|
||||
try:
|
||||
names = set(zf.namelist())
|
||||
if 'fabric.mod.json' in names:
|
||||
data = _decode(zf.read('fabric.mod.json'))
|
||||
depends = data.get('depends') or {}
|
||||
deps = [
|
||||
{'modId': k, 'type': 'required', 'versionRange': v}
|
||||
for k, v in depends.items() if k and v
|
||||
]
|
||||
return {
|
||||
'id': data.get('id') or '',
|
||||
'name': data.get('name') or data.get('id') or '',
|
||||
'version': data.get('version') or '',
|
||||
'modloader': 'fabric',
|
||||
'description': data.get('description') or '',
|
||||
'environment': data.get('environment') or '',
|
||||
'minecraft': depends.get('minecraft') or '',
|
||||
'dependencies': deps,
|
||||
}
|
||||
if 'quilt.mod.json' in names:
|
||||
data = _decode(zf.read('quilt.mod.json'))
|
||||
ql = data.get('quilt_loader') or {}
|
||||
meta = ql.get('metadata') or {}
|
||||
deps = []
|
||||
for d in ql.get('depends') or []:
|
||||
deps.append({
|
||||
'modId': d.get('id') or '',
|
||||
'type': d.get('reason') or 'required',
|
||||
'versionRange': d.get('versions') or '',
|
||||
})
|
||||
minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '')
|
||||
return {
|
||||
'id': ql.get('id') or '',
|
||||
'name': meta.get('name') or ql.get('id') or '',
|
||||
'version': ql.get('version') or '',
|
||||
'modloader': 'quilt',
|
||||
'description': meta.get('description') or '',
|
||||
'environment': '',
|
||||
'minecraft': minecraft,
|
||||
'dependencies': deps,
|
||||
}
|
||||
for member, loader in (('META-INF/neoforge.mods.toml', 'neoforge'), ('META-INF/mods.toml', 'forge')):
|
||||
if member in names:
|
||||
parsed = _parse_mods_toml(zf.read(member).decode('utf-8', 'replace'), loader)
|
||||
if parsed:
|
||||
return parsed
|
||||
if 'META-INF/MANIFEST.MF' in names:
|
||||
raw = zf.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace')
|
||||
title = _manifest_value(raw, 'Implementation-Title')
|
||||
version = _manifest_value(raw, 'Implementation-Version')
|
||||
if title or version:
|
||||
return {
|
||||
'id': title or '',
|
||||
'name': title or 'unknown',
|
||||
'version': version or '',
|
||||
'modloader': 'unknown',
|
||||
'description': '',
|
||||
'environment': '',
|
||||
'minecraft': '',
|
||||
'dependencies': [],
|
||||
}
|
||||
return None
|
||||
finally:
|
||||
zf.close()
|
||||
|
||||
|
||||
def _parse_mods_toml(text, loader):
|
||||
"""Parse a Forge/NeoForge mods.toml / neoforge.mods.toml."""
|
||||
try:
|
||||
import tomllib
|
||||
data = tomllib.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
mods = data.get('mods') or []
|
||||
if not mods:
|
||||
return None
|
||||
m = mods[0]
|
||||
mod_id = m.get('modId') or ''
|
||||
deps = []
|
||||
for d in (data.get('dependencies') or {}).get(mod_id) or []:
|
||||
deps.append({
|
||||
'modId': d.get('modId') or '',
|
||||
'type': d.get('type') or 'required',
|
||||
'versionRange': d.get('versionRange') or '',
|
||||
})
|
||||
minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '')
|
||||
return {
|
||||
'id': mod_id,
|
||||
'name': m.get('displayName') or mod_id,
|
||||
'version': m.get('version') or '',
|
||||
'modloader': loader,
|
||||
'description': m.get('description') or '',
|
||||
'environment': '',
|
||||
'minecraft': minecraft,
|
||||
'dependencies': deps,
|
||||
}
|
||||
|
||||
|
||||
def read_mod_version_manifest(file_entries):
|
||||
"""Build the Mods manifest for a mod-category version.
|
||||
|
||||
file_entries: list of (filename, path). Accepts standalone .jar files and
|
||||
.zip archives whose root holds .jar members (subfolders are ignored).
|
||||
Returns {mods: [...], no_mods: bool}.
|
||||
"""
|
||||
mods = []
|
||||
for filename, path in file_entries:
|
||||
lower = filename.lower()
|
||||
if lower.endswith('.jar'):
|
||||
meta = read_mod_manifest(path)
|
||||
if meta:
|
||||
if not meta.get('minecraft'):
|
||||
meta['minecraft'] = _mc_from_filename(filename)
|
||||
meta['file_name'] = filename
|
||||
meta['member'] = ''
|
||||
mods.append(meta)
|
||||
elif lower.endswith('.zip'):
|
||||
zf = _open_zip(path)
|
||||
if zf is None:
|
||||
continue
|
||||
try:
|
||||
root_jars = sorted(n for n in zf.namelist() if n.lower().endswith('.jar') and '/' not in n)
|
||||
for member in root_jars:
|
||||
meta = read_mod_manifest(zf.read(member))
|
||||
if meta:
|
||||
if not meta.get('minecraft'):
|
||||
meta['minecraft'] = _mc_from_filename(member)
|
||||
meta['file_name'] = member
|
||||
meta['member'] = member
|
||||
mods.append(meta)
|
||||
finally:
|
||||
zf.close()
|
||||
return {'mods': mods, 'no_mods': len(mods) == 0}
|
||||
|
||||
|
||||
def _in_plugins_dir(name):
|
||||
"""True when a member lives inside a plugins/ folder (any nesting)."""
|
||||
parts = name.split('/')
|
||||
|
||||
Reference in New Issue
Block a user