working Mods Cat

This commit is contained in:
JakeBreath
2026-08-05 10:25:53 -05:00
parent 435ad24da4
commit 358d29f9c9
6 changed files with 307 additions and 1 deletions
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-08-05 15:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0010_version_skins_manifest'),
]
operations = [
migrations.AddField(
model_name='version',
name='mod_manifest',
field=models.JSONField(blank=True, default=dict),
),
]
+1
View File
@@ -211,6 +211,7 @@ class Version(models.Model):
mods_manifest = models.JSONField(default=dict, blank=True)
plugins_manifest = models.JSONField(default=dict, blank=True)
skins_manifest = models.JSONField(default=dict, blank=True)
mod_manifest = models.JSONField(default=dict, blank=True)
animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True)
animation_manifest = models.JSONField(default=dict, blank=True)
models_manifest = models.JSONField(default=dict, blank=True)
+35 -1
View File
@@ -43,6 +43,7 @@ from .zips import (
read_pack_mcmeta,
read_plugins_manifest,
read_skins_manifest,
read_mod_version_manifest,
)
@@ -335,6 +336,7 @@ def project_detail(request, slug):
mods_manifest = None
plugins_manifest = None
skins_manifest = None
mod_manifest = None
anim_stats = None
latest = versions.first()
if project.category == 'guide' and latest is not None:
@@ -346,6 +348,7 @@ def project_detail(request, slug):
mods_manifest = latest.mods_manifest or None
plugins_manifest = latest.plugins_manifest or None
skins_manifest = latest.skins_manifest or None
mod_manifest = latest.mod_manifest or None
if animation_manifest:
anims = animation_manifest.get('animations') or []
entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player')
@@ -359,6 +362,16 @@ def project_detail(request, slug):
'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
}
mod_loaders = []
mod_count = 0
if mod_manifest:
mods = mod_manifest.get('mods') or []
mod_count = len(mods)
for m in mods:
loader = m.get('modloader')
if loader and loader not in mod_loaders:
mod_loaders.append(loader)
autoload_plugins_json = ''
if plugins_manifest:
members = [
@@ -380,6 +393,9 @@ def project_detail(request, slug):
'mods_manifest': mods_manifest,
'plugins_manifest': plugins_manifest,
'skins_manifest': skins_manifest,
'mod_manifest': mod_manifest,
'mod_loaders': mod_loaders,
'mod_count': mod_count,
'autoload_plugins_json': autoload_plugins_json,
'anim_stats': anim_stats,
}
@@ -657,6 +673,15 @@ def _reparse_version(version):
version.logical_manifest = read_logical_manifest(path) or {}
version.plugins_manifest = read_plugins_manifest(path) or {} if version.project.category == 'model' else {}
version.skins_manifest = read_skins_manifest(path) or {} if version.project.category == 'skin' else {}
if version.project.category == 'mod':
entries = [
(vf.filename, _stored_path(vf.file))
for vf in version.files.select_related('file').all()
if vf.filename.lower().endswith(('.jar', '.zip'))
]
version.mod_manifest = read_mod_version_manifest(entries)
else:
version.mod_manifest = {}
manifest = parse_mods_manifest(path, release.file.original_filename)
version.mods_manifest = manifest if manifest.get('files') else {}
fmt, desc = read_pack_mcmeta(path)
@@ -680,6 +705,7 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
mods_manifest = {}
plugins_manifest = None
skins_manifest = None
mod_file_entries = []
animation_id = ''
animation_manifest = None
models_manifest = None
@@ -696,7 +722,12 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
temp.status = 'used'
temp.save(update_fields=['status'])
is_archive = (index.original_filename or '').lower().endswith(('.zip', '.mrpack'))
if project.category == 'mod':
mod_name = (index.original_filename or '').lower()
if mod_name.endswith(('.jar', '.zip')):
mod_file_entries.append((index.original_filename, _stored_path(index)))
is_archive = (index.original_filename or '').lower().endswith(('.zip', '.mrpack', '.jar'))
if not is_archive:
continue
path = _stored_path(index)
@@ -741,6 +772,9 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
if skins_manifest is not None:
version.skins_manifest = skins_manifest
update_fields.append('skins_manifest')
if project.category == 'mod' and mod_file_entries:
version.mod_manifest = read_mod_version_manifest(mod_file_entries)
update_fields.append('mod_manifest')
if animation_id:
version.animation_id = animation_id
update_fields.append('animation_id')
+166
View File
@@ -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('/')