From 50a70bfe31ee3534765ff73701b10db0d28eedab Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Tue, 4 Aug 2026 20:23:32 -0500 Subject: [PATCH] added ModPacks support --- nonpacks/library/views.py | 10 +- nonpacks/library/zips.py | 136 +++++++++++++++--- nonpacks/static/css/style.css | 72 ++++++++++ .../templates/library/project_detail.html | 38 ++++- 4 files changed, 227 insertions(+), 29 deletions(-) diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index 5c6e9bc..cb47643 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -638,6 +638,8 @@ def _reparse_version(version): version.animation_manifest = read_animation_manifest(path) or {} version.models_manifest = read_models_manifest(path) or {} version.logical_manifest = read_logical_manifest(path) or {} + manifest = parse_mods_manifest(path, release.file.original_filename) + version.mods_manifest = manifest if manifest.get('files') else {} fmt, desc = read_pack_mcmeta(path) if fmt is not None: version.pack_format = fmt @@ -673,8 +675,8 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): temp.status = 'used' temp.save(update_fields=['status']) - is_zip = (index.original_filename or '').lower().endswith('.zip') - if not is_zip: + is_archive = (index.original_filename or '').lower().endswith(('.zip', '.mrpack')) + if not is_archive: continue path = _stored_path(index) @@ -780,7 +782,7 @@ def project_create(request): version_files, request.user, ) - if missing_animation_id: + if missing_animation_id and project.category == 'non_pack': messages.warning(request, ANIMATION_API_WARNING) for temp in TempUpload.objects.filter( @@ -935,7 +937,7 @@ def version_upload(request, slug): version_files, request.user, ) - if missing_animation_id: + if missing_animation_id and project.category == 'non_pack': messages.warning(request, ANIMATION_API_WARNING) messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.') return redirect('library:project_detail', slug=project.slug) diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py index 67cf394..8239e57 100644 --- a/nonpacks/library/zips.py +++ b/nonpacks/library/zips.py @@ -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, /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), + } diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index ed37ffc..c682624 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -3329,3 +3329,75 @@ a.deletelink { border: none; background: #121418; } + +/* Modpack badges + Mods tab */ +.modpack-badge { + background: #1d3557; + color: #e6f1ff; + border-radius: 999px; + padding: 0.15rem 0.6rem; + font-size: 0.75rem; +} +.mods-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.mods-source-badge { + background: var(--md-sys-color-primary-container, #dbe4ff); + color: var(--md-sys-color-on-primary-container, #001b3f); + border-radius: 999px; + padding: 0.15rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} +.mods-stat { + color: var(--md-sys-color-on-surface-variant, #555); + font-size: 0.8rem; +} +.mods-filter { + width: 100%; + max-width: 420px; + padding: 0.45rem 0.7rem; + border-radius: 8px; + border: 1px solid var(--md-sys-color-outline, #ccc); + background: var(--md-sys-color-surface, #fff); + color: inherit; + margin-bottom: 0.75rem; +} +.mods-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.mods-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + border-radius: 6px; + background: var(--md-sys-color-surface-container-low, #f7f7f9); + font-size: 0.85rem; +} +.mods-item:hover { background: var(--md-sys-color-surface-container, #efeff3); } +.mods-name { font-weight: 600; } +.mods-file { color: var(--md-sys-color-on-surface-variant, #777); font-size: 0.78rem; } +.mods-badge { + margin-left: auto; + border-radius: 999px; + padding: 0.1rem 0.5rem; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; +} +.mods-badge.optional { background: #fff4d6; color: #7a5c00; } +.mods-badge.client { background: #d9f2ff; color: #00567a; } +.mods-badge.server { background: #e6e0ff; color: #3a2d8f; } +.mods-link { color: inherit; text-decoration: none; opacity: 0.6; } +.mods-link:hover { opacity: 1; } diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index f5d85bb..3d5bde1 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -17,6 +17,11 @@

{{ project.title }}

{{ project.get_category_display }} + {% if mods_manifest %} + {% if mods_manifest.minecraft %}{{ mods_manifest.minecraft }}{% endif %} + {% for loader in mods_manifest.modloaders %}{{ loader }}{% endfor %} + {{ mods_manifest.totals.total }} mods + {% endif %} #{{ project.pk }}
{% if project.summary %}

{{ project.summary }}

{% endif %} @@ -347,10 +352,26 @@

Mods

{% if mods_manifest.files %} - {{ mods_manifest.source }} -
    +
    + {{ mods_manifest.source }} + {% if mods_manifest.minecraft %}MC {{ mods_manifest.minecraft }}{% endif %} + {% if mods_manifest.modloaders %}{{ mods_manifest.modloaders|join:' / ' }}{% endif %} + {{ mods_manifest.totals.total }} mods + {% if mods_manifest.totals.required is not None %} + {{ mods_manifest.totals.required }} required + {{ mods_manifest.totals.optional }} optional + {% endif %} +
    + +
      {% for mod in mods_manifest.files %} -
    • {{ mod.name }}
    • +
    • + + {{ mod.name }} + {% if mod.file_name and mod.file_name != mod.name %}{{ mod.file_name }}{% endif %} + {% if mod.env == 'client' %}client{% elif mod.env == 'server' %}server{% elif mod.env == 'optional' or mod.required is False %}optional{% endif %} + {% if mod.url %}{% endif %} +
    • {% endfor %}
    {% else %} @@ -496,6 +517,17 @@ btn.addEventListener('click', () => window.PacksPreview.open(btn)); }); } + + // Live filter for the Mods tab. + const modsFilter = document.getElementById('mods-filter'); + if (modsFilter) { + modsFilter.addEventListener('input', () => { + const q = modsFilter.value.toLowerCase(); + document.querySelectorAll('#mods-list .mods-item').forEach(item => { + item.style.display = item.textContent.toLowerCase().includes(q) ? '' : 'none'; + }); + }); + } })(); {% endblock %}