added ModPacks support
This commit is contained in:
@@ -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)
|
||||
|
||||
+114
-22
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
<div class="project-title-row">
|
||||
<h1>{{ project.title }}</h1>
|
||||
<span class="category-badge">{{ project.get_category_display }}</span>
|
||||
{% if mods_manifest %}
|
||||
{% if mods_manifest.minecraft %}<span class="category-badge modpack-badge" title="Minecraft version">{{ mods_manifest.minecraft }}</span>{% endif %}
|
||||
{% for loader in mods_manifest.modloaders %}<span class="category-badge modpack-badge" title="Modloader">{{ loader }}</span>{% endfor %}
|
||||
<span class="category-badge modpack-badge" title="Mod count">{{ mods_manifest.totals.total }} mods</span>
|
||||
{% endif %}
|
||||
<span class="project-id-badge" title="Project ID">#{{ project.pk }}</span>
|
||||
</div>
|
||||
{% if project.summary %}<p class="project-summary">{{ project.summary }}</p>{% endif %}
|
||||
@@ -347,10 +352,26 @@
|
||||
<div class="card">
|
||||
<h2><i class="fas fa-cubes"></i> Mods</h2>
|
||||
{% if mods_manifest.files %}
|
||||
<div class="mods-summary">
|
||||
<span class="mods-source-badge">{{ mods_manifest.source }}</span>
|
||||
<ul class="mods-list">
|
||||
{% if mods_manifest.minecraft %}<span class="mods-stat">MC {{ mods_manifest.minecraft }}</span>{% endif %}
|
||||
{% if mods_manifest.modloaders %}<span class="mods-stat">{{ mods_manifest.modloaders|join:' / ' }}</span>{% endif %}
|
||||
<span class="mods-stat">{{ mods_manifest.totals.total }} mods</span>
|
||||
{% if mods_manifest.totals.required is not None %}
|
||||
<span class="mods-stat">{{ mods_manifest.totals.required }} required</span>
|
||||
<span class="mods-stat">{{ mods_manifest.totals.optional }} optional</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<input type="search" class="mods-filter" id="mods-filter" placeholder="Filter mods…" aria-label="Filter mods">
|
||||
<ul class="mods-list" id="mods-list">
|
||||
{% for mod in mods_manifest.files %}
|
||||
<li><i class="fas fa-cube"></i> {{ mod.name }}</li>
|
||||
<li class="mods-item">
|
||||
<i class="fas fa-cube"></i>
|
||||
<span class="mods-name">{{ mod.name }}</span>
|
||||
{% if mod.file_name and mod.file_name != mod.name %}<span class="mods-file">{{ mod.file_name }}</span>{% endif %}
|
||||
{% if mod.env == 'client' %}<span class="mods-badge client">client</span>{% elif mod.env == 'server' %}<span class="mods-badge server">server</span>{% elif mod.env == 'optional' or mod.required is False %}<span class="mods-badge optional">optional</span>{% endif %}
|
||||
{% if mod.url %}<a class="mods-link" href="{{ mod.url }}" target="_blank" rel="noopener noreferrer" title="Open project page"><i class="fas fa-external-link-alt"></i></a>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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';
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user