diff --git a/nonpacks/library/migrations/0011_version_mod_manifest.py b/nonpacks/library/migrations/0011_version_mod_manifest.py new file mode 100644 index 0000000..2b8471a --- /dev/null +++ b/nonpacks/library/migrations/0011_version_mod_manifest.py @@ -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), + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index 4378d6d..8403ea7 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -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) diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index f809a15..ba54a8d 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -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') diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py index 98521cd..6273792 100644 --- a/nonpacks/library/zips.py +++ b/nonpacks/library/zips.py @@ -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('/') diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index 6da4ff7..1229f1f 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -3554,3 +3554,49 @@ a.deletelink { } .skin-render-btn { width: 100%; } + +/* Mod Info tab */ +.mods-warning { + display: flex; + align-items: flex-start; + gap: 0.5rem; + background: #fff4e0; + border: 1px solid #f0c878; + color: #7a4d00; + padding: 0.7rem 0.9rem; + border-radius: 8px; + margin-bottom: 0.9rem; + font-size: 0.85rem; +} +.mod-card { + background: var(--md-sys-color-surface-variant, #45475a); + border: 1px solid var(--md-sys-color-outline-variant, #3a3f4d); + border-radius: 10px; + padding: 0.9rem; + margin-bottom: 0.75rem; +} +.mod-card-head { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + color: var(--md-sys-color-on-surface, #cdd6f4); + font-size: 1rem; +} +.mod-loader-badge { + border-radius: 999px; + background: #e0e7ff; + color: #3730a3; + padding: 0.1rem 0.55rem; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} +.mod-version { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.85rem; } +.mod-id { margin: 0.3rem 0 0; color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.78rem; } +.mod-desc { margin: 0.4rem 0; color: var(--md-sys-color-on-surface, #cdd6f4); font-size: 0.88rem; } +.mod-meta { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.78rem; } +.mod-deps { list-style: none; margin: 0.5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.8rem; } +.mod-deps li { color: var(--md-sys-color-on-surface-variant, #a6adc8); } +.mod-deps code { background: #313244; color: #cdd6f4; padding: 0 0.3rem; border-radius: 4px; } +.mod-dep-type { color: #8a94b0; font-size: 0.72rem; } diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index 8093ef2..9eaa742 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -22,6 +22,10 @@ {% for loader in mods_manifest.modloaders %}{{ loader }}{% endfor %} {{ mods_manifest.totals.total }} mods {% endif %} + {% if mod_manifest %} + {% for loader in mod_loaders %}{{ loader }}{% endfor %} + {% if mod_count %}{{ mod_count }} mods{% endif %} + {% endif %} #{{ project.pk }} {% if project.summary %}
{{ project.summary }}
{% endif %} @@ -75,6 +79,7 @@ {% if mods_manifest %}{% endif %} {% if plugins_manifest %}{% endif %} {% if skins_manifest %}{% endif %} + {% if mod_manifest %}{% endif %} @@ -453,6 +458,42 @@ {% endif %} +{% if mod_manifest %} +{{ mod.id }}
{% endif %} + {% if mod.description %}{{ mod.description }}
{% endif %} + + {% if mod.dependencies %} +{{ dep.versionRange }}{% endif %} ({{ dep.type }})No mod metadata could be read from the uploaded files.
{% endif %} + {% endfor %} +