Finished with Phase 2.6

This commit is contained in:
2026-08-04 01:46:58 -05:00
parent 529f3e5c3d
commit a595c4e68b
11 changed files with 1350 additions and 41 deletions
@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-08-04 05:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0007_version_animation_id_version_animation_manifest'),
]
operations = [
migrations.AddField(
model_name='version',
name='logical_manifest',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='version',
name='models_manifest',
field=models.JSONField(blank=True, default=dict),
),
]
+6
View File
@@ -211,6 +211,8 @@ class Version(models.Model):
mods_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)
logical_manifest = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -277,6 +279,10 @@ class ProjectAsset(models.Model):
def is_video(self):
return self.file.content_type.startswith('video/')
@property
def is_gif(self):
return self.file.content_type == 'image/gif'
class TempUpload(models.Model):
"""J621-style draft upload. Stored under media/uploads/user_<id>/<uuid><ext>
+254
View File
@@ -4,6 +4,7 @@ import os
import secrets
import shutil
import tempfile
import unittest
import zipfile
from django.contrib.auth import get_user_model
@@ -1320,3 +1321,256 @@ class UGCAnimationTests(UGCMediaTestCase, UGCGatedTestCase):
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
self.assertEqual(resp.status_code, 404)
def _geo_model_zip():
geo = {
'format_version': '1.12.0',
'afw_bone_textures': {'body': 'needsofnature:textures/entity/zombie/zombie.png'},
'minecraft:geometry': [{
'description': {'identifier': 'geometry.unknown', 'texture_width': 64, 'texture_height': 64},
'bones': [
{'name': 'root', 'pivot': [0, 0, 0]},
{'name': 'body', 'pivot': [0, 12, 0], 'cubes': [
{'origin': [-4, 0, -4], 'size': [8, 8, 8], 'uv': [0, 0]},
]},
],
}],
}
return _zip_bytes({
'pack.mcmeta': json.dumps({
'pack': {'pack_format': 88},
'animationframework': {'id': 'christmaspuffin:models_and_textures', 'name': 'Models',
'author': 'CP', 'version': '1.0'},
}),
'assets/animationframework/geckolib/models/entity/zombie.m.geo.json': json.dumps(geo),
'assets/needsofnature/textures/entity/zombie/zombie.png': b'PNG-DATA',
})
def _logical_zip():
return _zip_bytes({
'pack.mcmeta': json.dumps({'pack': {'pack_format': 61}}),
'assets/usefulcum/items/potion_gender.json': json.dumps(
{'model': {'type': 'minecraft:model', 'model': 'usefulcum:item/potion_gender'}}),
'assets/usefulcum/textures/item/potion_gender.png': b'PNG',
'data/usefulcum/recipe/cum_combination.json': json.dumps({
'type': 'minecraft:crafting_shapeless',
'ingredients': ['minecraft:potion', 'minecraft:potion'],
'result': {'id': 'minecraft:glass_bottle', 'count': 2},
}),
'data/usefulcum/advancement/recipe/blaze_cum.json': json.dumps(
{'rewards': {'function': 'usefulcum:recipe/blaze_cum'}}),
'data/usefulcum/advancement/change_gender.json': json.dumps(
{'rewards': {'function': 'usefulcum:change_gender'}}),
'data/usefulcum/function/change_gender.mcfunction': b'# x',
'data/usefulcum/function/tick.mcfunction': b'# y',
})
class UGCContentTabsTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
def _write_zip(self, data):
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
f.write(data)
return f.name
def _upload_version(self, slug, name, content):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
return self.client.post(
reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'},
)
def test_read_pack_meta_without_animdefs(self):
from library.zips import read_pack_meta
path = self._write_zip(_geo_model_zip())
try:
meta = read_pack_meta(path)
finally:
os.unlink(path)
self.assertEqual(meta['animation_id'], 'christmaspuffin:models_and_textures')
self.assertEqual(meta['authors'], ['CP'])
def test_read_models_manifest(self):
from library.zips import read_models_manifest
path = self._write_zip(_geo_model_zip())
try:
manifest = read_models_manifest(path)
finally:
os.unlink(path)
self.assertIsNotNone(manifest)
model = manifest['models'][0]
self.assertEqual(model['name'], 'zombie.m.geo.json')
self.assertEqual(model['bones'], 2)
self.assertEqual(model['cubes'], 1)
self.assertEqual(
model['bone_textures']['body'],
'assets/needsofnature/textures/entity/zombie/zombie.png',
)
def test_read_logical_manifest(self):
from library.zips import read_logical_manifest
path = self._write_zip(_logical_zip())
try:
manifest = read_logical_manifest(path)
finally:
os.unlink(path)
self.assertIsNotNone(manifest)
self.assertEqual(manifest['items'][0]['name'], 'usefulcum:potion_gender')
self.assertEqual(manifest['recipes'][0]['ingredients'], ['minecraft:potion'])
self.assertEqual(manifest['recipes'][0]['result'], 'minecraft:glass_bottle x2')
self.assertEqual(manifest['custom_recipes'][0]['name'], 'blaze_cum')
self.assertEqual(manifest['advancements'][0]['name'], 'change_gender')
self.assertEqual(len(manifest['functions']), 2)
def test_models_pack_gets_id_and_models_tab(self):
self.project = Project.objects.create(
slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
)
self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
version = self.project.versions.first()
self.assertEqual(version.animation_id, 'christmaspuffin:models_and_textures')
self.assertTrue(version.models_manifest)
self.assertFalse(version.animation_manifest)
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['models-pack']))
self.assertContains(resp, 'tab-models')
self.assertContains(resp, 'Render')
self.assertNotContains(resp, 'tab-animations')
def test_logical_pack_shows_only_logical_tab(self):
self.project = Project.objects.create(
slug='logical-pack', title='Logical Pack', category='non_pack', owner=self.alice,
)
self._upload_version('logical-pack', 'pack.zip', _logical_zip())
version = self.project.versions.first()
self.assertTrue(version.logical_manifest)
self.assertFalse(version.models_manifest)
self.assertFalse(version.animation_manifest)
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['logical-pack']))
self.assertContains(resp, 'tab-logical')
self.assertNotContains(resp, 'tab-models')
self.assertNotContains(resp, 'tab-animations')
self.assertContains(resp, 'usefulcum:potion_gender')
def test_pack_asset_serves_member(self):
self.project = Project.objects.create(
slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
)
self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
version = self.project.versions.first()
self.gate()
resp = self.client.get(reverse(
'library:pack_asset',
args=['models-pack', version.pk, 'assets/needsofnature/textures/entity/zombie/zombie.png'],
))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp['Content-Type'].split(';')[0], 'image/png')
self.assertEqual(resp.content, b'PNG-DATA')
def test_pack_asset_rejects_unknown_member(self):
self.project = Project.objects.create(
slug='models-pack', title='Models Pack', category='non_pack', owner=self.alice,
)
self._upload_version('models-pack', 'pack.zip', _geo_model_zip())
version = self.project.versions.first()
self.gate()
resp = self.client.get(reverse(
'library:pack_asset',
args=['models-pack', version.pk, 'assets/nope/nothing.png'],
))
self.assertEqual(resp.status_code, 404)
def test_gallery_video_is_muted(self):
from library.storage import store_file
self.project = Project.objects.create(
slug='muted', title='Muted', category='mod', owner=self.alice,
)
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm'),
self.project.pk, subdir='gallery',
)
ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['muted']))
self.assertContains(resp, 'muted')
def make_gif_bytes():
import PIL.Image
frames = [PIL.Image.new('RGB', (8, 8), c) for c in ('red', 'green')]
buf = io.BytesIO()
frames[0].save(buf, format='GIF', save_all=True, append_images=frames[1:], duration=100, loop=0)
return buf.getvalue()
class UGCThumbnailTests(UGCMediaTestCase, UGCGatedTestCase):
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg required')
def test_video_and_gif_thumbnails(self):
User = get_user_model()
alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=alice)
project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice)
from library.storage import store_file
gif = ProjectAsset.objects.create(
project=project, uploaded_by=alice,
file=store_file(
alice, 'asset',
SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'),
project.pk, subdir='gallery',
),
)
png = ProjectAsset.objects.create(
project=project, uploaded_by=alice,
file=store_file(
alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
project.pk, subdir='gallery',
),
)
self.gate()
# Gif gets a cached JPEG thumbnail.
resp = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk]))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp['Content-Type'].split(';')[0], 'image/jpeg')
self.assertGreater(len(b''.join(resp.streaming_content)), 0)
# Cached on the second request.
resp2 = self.client.get(reverse('library:asset_thumbnail', args=['media', gif.pk]))
self.assertEqual(resp2.status_code, 200)
# A plain image is not thumbnailed → 404.
resp3 = self.client.get(reverse('library:asset_thumbnail', args=['media', png.pk]))
self.assertEqual(resp3.status_code, 404)
def test_gallery_grid_uses_thumbnail_for_gif(self):
User = get_user_model()
alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=alice)
project = Project.objects.create(slug='media', title='Media', category='mod', owner=alice)
from library.storage import store_file
ProjectAsset.objects.create(
project=project, uploaded_by=alice,
file=store_file(
alice, 'asset',
SimpleUploadedFile('anim.gif', make_gif_bytes(), content_type='image/gif'),
project.pk, subdir='gallery',
),
)
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['media']))
self.assertContains(resp, '/thumb/')
+60
View File
@@ -0,0 +1,60 @@
"""ffmpeg thumbnail generation for gallery media (videos + gifs).
Grid previews use a small cached JPEG extracted by ffmpeg; the browser only
receives the real file when the full-screen modal opens.
"""
import shutil
import subprocess
from pathlib import Path
from django.conf import settings
FFMPEG = shutil.which('ffmpeg')
MAX_WIDTH = 480
def thumbnail_stored_path(file_index):
"""Relative stored path of the cached thumbnail for an asset file."""
return f'thumbnails/{file_index.uuid}.jpg'
def thumbnail_ready(file_index):
return (Path(settings.MEDIA_ROOT) / thumbnail_stored_path(file_index)).is_file()
def generate_thumbnail(file_index):
"""Generate (and cache) a JPEG thumbnail for a video/gif FileIndex.
Returns the relative stored path, or None when ffmpeg is unavailable or
extraction fails. Idempotent — a cached thumbnail is reused.
"""
if not FFMPEG or not file_index.stored_path:
return None
src = (Path(settings.MEDIA_ROOT) / file_index.stored_path).resolve()
if not src.is_file():
return None
out_rel = thumbnail_stored_path(file_index)
out = (Path(settings.MEDIA_ROOT) / out_rel).resolve()
if out.is_file():
return out_rel
out.parent.mkdir(parents=True, exist_ok=True)
tmp = out.with_suffix('.tmp.jpg')
cmd = [
FFMPEG, '-y', '-i', str(src),
'-frames:v', '1',
'-vf', f"scale='min({MAX_WIDTH},iw)':-2",
'-q:v', '4',
str(tmp),
]
try:
result = subprocess.run(cmd, capture_output=True, timeout=30)
except (subprocess.TimeoutExpired, OSError):
return None
if result.returncode != 0 or not tmp.is_file():
return None
tmp.replace(out)
return out_rel
+2
View File
@@ -13,8 +13,10 @@ urlpatterns = [
path('packs/<slug:slug>/versions/upload/', views.version_upload, name='version_upload'),
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
path('packs/<slug:slug>/versions/<int:version_id>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
path('packs/<slug:slug>/versions/<int:version_id>/asset/<path:asset_path>', views.pack_asset, name='pack_asset'),
path('packs/<slug:slug>/guide/<int:version_id>/<uuid:file_uuid>/', views.guide_doc, name='guide_doc'),
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
+105 -18
View File
@@ -1,5 +1,7 @@
import json
import mimetypes
import posixpath
import zipfile
from pathlib import Path
from django.conf import settings
@@ -30,7 +32,14 @@ from .models import (
slugify_tag,
)
from .storage import delete_file_index, move_file_index, store_temp_file
from .zips import parse_mods_manifest, read_animation_manifest, read_pack_mcmeta
from .zips import (
parse_mods_manifest,
read_animation_manifest,
read_logical_manifest,
read_models_manifest,
read_pack_meta,
read_pack_mcmeta,
)
def _open_indexed_file(file_index):
@@ -317,12 +326,19 @@ def project_detail(request, slug):
guide_docs = []
animation_manifest = None
models_manifest = None
logical_manifest = None
mods_manifest = None
anim_stats = None
latest = versions.first()
if project.category == 'guide' and latest is not None:
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
if latest is not None and latest.animation_manifest:
animation_manifest = latest.animation_manifest
if latest is not None:
animation_manifest = latest.animation_manifest or None
models_manifest = latest.models_manifest or None
logical_manifest = latest.logical_manifest or None
mods_manifest = latest.mods_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')
solo_count = sum(1 for a in anims if a.get('type') == 'Solo')
@@ -343,11 +359,37 @@ def project_detail(request, slug):
'can_edit': can_edit,
'guide_docs': guide_docs,
'animation_manifest': animation_manifest,
'models_manifest': models_manifest,
'logical_manifest': logical_manifest,
'mods_manifest': mods_manifest,
'anim_stats': anim_stats,
}
return render(request, 'library/project_detail.html', context)
def pack_asset(request, slug, version_id, asset_path):
"""Serve a named member from a stored version zip (gated), used for the
Models/Textures + Logical tabs and the model viewer."""
version = get_object_or_404(
Version.objects.select_related('project'),
pk=version_id, project__slug=slug,
)
if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
raise Http404
vf = (
version.files.select_related('file').filter(kind='release').first()
or version.files.select_related('file').first()
)
if vf is None:
raise Http404
with zipfile.ZipFile(_stored_path(vf.file)) as zf:
if asset_path not in zf.namelist():
raise Http404
data = zf.read(asset_path)
content_type = mimetypes.guess_type(asset_path)[0] or 'application/octet-stream'
return HttpResponse(data, content_type=content_type)
@login_required
def api_packs_latest(request, namespace, pack_id):
"""Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
@@ -483,8 +525,8 @@ def _apply_content_tags(project, tag_names, actor):
def _finalize_version(project, version_name, changelog, temp_uploads, actor):
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles,
capturing category-specific metadata (pack.mcmeta / mods manifest / animation
manifest). Returns (version, missing_animation_id)."""
capturing category/content-specific metadata (pack.mcmeta, mods, animation,
models, logical manifests). Returns (version, missing_animation_id)."""
version = Version.objects.create(
project=project,
version_name=version_name,
@@ -493,7 +535,10 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
pack_format = None
pack_description = ''
mods_manifest = {}
animation_id = ''
animation_manifest = None
models_manifest = None
logical_manifest = None
missing_animation_id = False
for temp in temp_uploads:
@@ -507,21 +552,31 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
temp.save(update_fields=['status'])
is_zip = (index.original_filename or '').lower().endswith('.zip')
if project.category == 'non_pack' and is_zip and pack_format is None:
fmt, desc = read_pack_mcmeta(_stored_path(index))
if not is_zip:
continue
path = _stored_path(index)
if project.category == 'non_pack' and pack_format is None:
fmt, desc = read_pack_mcmeta(path)
if fmt is not None:
pack_format = fmt
pack_description = desc
elif project.category == 'modpack' and is_zip and not mods_manifest:
manifest = parse_mods_manifest(_stored_path(index), index.original_filename)
if not animation_id:
meta = read_pack_meta(path)
if meta and meta.get('animation_id'):
animation_id = meta['animation_id']
if animation_manifest is None:
animation_manifest = read_animation_manifest(path)
if models_manifest is None:
models_manifest = read_models_manifest(path)
if logical_manifest is None:
logical_manifest = read_logical_manifest(path)
if not mods_manifest:
manifest = parse_mods_manifest(path, index.original_filename)
if manifest.get('files'):
mods_manifest = manifest
if is_zip and animation_manifest is None:
manifest = read_animation_manifest(_stored_path(index))
if manifest is not None:
animation_manifest = manifest
if not manifest.get('animation_id'):
missing_animation_id = True
update_fields = []
if pack_format is not None:
@@ -531,12 +586,20 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
if mods_manifest:
version.mods_manifest = mods_manifest
update_fields.append('mods_manifest')
if animation_id:
version.animation_id = animation_id
update_fields.append('animation_id')
if animation_manifest is not None:
version.animation_manifest = animation_manifest
update_fields.append('animation_manifest')
if animation_manifest.get('animation_id'):
version.animation_id = animation_manifest['animation_id']
update_fields.append('animation_id')
if not animation_id:
missing_animation_id = True
if models_manifest is not None:
version.models_manifest = models_manifest
update_fields.append('models_manifest')
if logical_manifest is not None:
version.logical_manifest = logical_manifest
update_fields.append('logical_manifest')
if update_fields:
version.save(update_fields=update_fields)
@@ -826,6 +889,30 @@ def asset_delete(request, slug, asset_id):
return redirect('library:project_detail', slug=project.slug)
def asset_thumbnail(request, slug, asset_id):
"""Cached ffmpeg thumbnail for a gallery video/gif (grid preview only;
the modal loads the real file)."""
from pathlib import Path as _Path
from django.http import FileResponse
from .thumbnails import generate_thumbnail
asset = get_object_or_404(
ProjectAsset.objects.select_related('file', 'project'),
pk=asset_id, project__slug=slug,
)
if not (asset.is_video or asset.is_gif):
raise Http404
stored = generate_thumbnail(asset.file)
if stored is None:
raise Http404
fh = open(_Path(settings.MEDIA_ROOT) / stored, 'rb')
response = FileResponse(fh, content_type='image/jpeg')
response['Cache-Control'] = 'public, max-age=86400'
return response
@login_required
def contributors(request, slug):
project = get_object_or_404(Project, slug=slug)
+226
View File
@@ -149,6 +149,232 @@ def _summarize_animdef(filename, data):
}
def read_pack_meta(path):
"""Read the animationframework block from pack.mcmeta regardless of whether
the pack has animdefs (models/textures packs). None when absent."""
zf = _open_zip(path)
if zf is None:
return None
try:
try:
mcmeta = _decode(zf.read('pack.mcmeta'))
except (KeyError, json.JSONDecodeError):
return None
af = mcmeta.get('animationframework')
if not af:
return None
authors = []
if af.get('author'):
authors = [a.strip() for a in str(af['author']).split(',') if a.strip()]
return {
'animation_id': af.get('id') or None,
'name': str(af.get('name') or ''),
'version': str(af.get('version') or ''),
'authors': authors,
'description': str(af.get('description') or ''),
}
finally:
zf.close()
def _resource_to_member(resource):
"""needsofnature:textures/entity/zombie/zombie.png → assets/needsofnature/..."""
if ':' in resource:
ns, _, path = resource.partition(':')
return f'assets/{ns}/{path}'
if resource.startswith('assets/'):
return resource
return f'assets/{resource}'
def read_models_manifest(path):
"""Build the Models/Textures manifest from GeckoLib geo models.
Returns None when the pack has no geo models. Each model records its geo
filename, identifier, bone/cube counts, and the afw_bone_textures map
(bone → texture zip member) so the viewer can texture it."""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
geo_names = sorted(
n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json')
)
if not geo_names:
return None
models = []
for name in geo_names:
try:
data = _decode(zf.read(name))
except (KeyError, json.JSONDecodeError):
continue
bone_textures = {}
for bone, tex in (data.get('afw_bone_textures') or {}).items():
member = _resource_to_member(str(tex))
if member in names:
bone_textures[str(bone)] = member
bone_count = 0
cube_count = 0
identifier = ''
for geom in data.get('minecraft:geometry') or []:
identifier = identifier or (geom.get('description') or {}).get('identifier', '')
for bone in geom.get('bones') or []:
bone_count += 1
cube_count += len(bone.get('cubes') or [])
models.append({
'name': name.rsplit('/', 1)[-1],
'member': name,
'identifier': identifier,
'bones': bone_count,
'cubes': cube_count,
'bone_textures': bone_textures,
'textures': sorted(set(bone_textures.values())),
})
textures = sorted(n for n in names if n.startswith('assets/') and n.endswith('.png'))
return {'models': models, 'textures': textures}
finally:
zf.close()
def _item_name_from_member(member):
parts = member.split('/')
try:
idx = parts.index('items')
ns = parts[idx - 1]
name = parts[idx + 1]
if name.endswith('.json'):
name = name[:-5]
return f'{ns}:{name}'
except (ValueError, IndexError):
return member
def _ingredient_names(recipe):
if isinstance(recipe.get('key'), dict):
raw = list(recipe['key'].values())
elif 'ingredients' in recipe:
raw = recipe['ingredients']
else:
raw = []
names = set()
for item in raw:
if isinstance(item, dict):
item = item.get('id')
if item:
names.add(str(item))
return sorted(names)
def _result_name(recipe):
res = recipe.get('result')
if isinstance(res, dict):
rid = res.get('id') or res.get('item') or ''
count = res.get('count')
if count and int(count) != 1:
return f'{rid} x{count}'
return str(rid)
return str(res) if res else ''
def read_logical_manifest(path):
"""Build the Logical manifest outlining what a datapack adds (items,
recipes, custom crafting, advancements, functions, NoN extras)."""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
def _dir_members(subdir, suffix):
"""data/<ns>/<subdir>/<file> or assets/<ns>/<subdir>/<file> (4 parts)."""
out = []
for n in names:
parts = n.split('/')
if len(parts) == 4 and parts[2] == subdir and n.endswith(suffix):
out.append(n)
return sorted(out)
items = _dir_members('items', '.json')
recipes = _dir_members('recipe', '.json')
functions = sorted(
n for n in names
if n.startswith('data/') and '/function/' in n and n.endswith('.mcfunction')
)
liquid_gains = [n for n in names if '/non_liquid_gains/' in n]
entity_profiles = [n for n in names if '/non_entity_profiles/' in n]
trinkets = [n for n in names if '/trinkets/' in n]
if not any([items, recipes, functions, liquid_gains, entity_profiles, trinkets]):
return None
advancement_members = sorted(
n for n in names
if n.startswith('data/') and '/advancement/' in n and n.endswith('.json')
)
custom_recipes = [n for n in advancement_members if '/advancement/recipe/' in n]
advancements_plain = [n for n in advancement_members if '/advancement/recipe/' not in n]
item_list = []
for member in items:
name = _item_name_from_member(member)
ns, _, base = name.partition(':')
tex_member = f'assets/{ns}/textures/item/{base}.png'
item_list.append({
'name': name,
'texture': tex_member if tex_member in names else None,
})
recipe_list = []
for member in recipes:
base = member.rsplit('/', 1)[-1]
disabled = '.disabled' in base
clean = base.replace('.json', '').replace('.disabled', '')
try:
data = _decode(zf.read(member))
except (KeyError, json.JSONDecodeError):
data = {}
recipe_list.append({
'name': clean,
'type': (data.get('type') or '').rsplit(':', 1)[-1],
'ingredients': _ingredient_names(data),
'result': _result_name(data),
'disabled': disabled,
})
custom_recipes_out = []
advancements_out = []
for member in advancement_members:
try:
data = _decode(zf.read(member))
except (KeyError, json.JSONDecodeError):
continue
reward = (data.get('rewards') or {}).get('function')
name = member.rsplit('/', 1)[-1][:-5]
if member in custom_recipes:
custom_recipes_out.append({'name': name, 'reward': reward})
else:
advancements_out.append({'name': name, 'reward': reward})
function_list = sorted(n for n in functions if '/recipe/' not in n)
return {
'items': item_list,
'recipes': recipe_list,
'custom_recipes': custom_recipes_out,
'advancements': advancements_out,
'functions': function_list,
'custom_functions': len(functions) - len(function_list),
'liquid_gains': liquid_gains,
'entity_profiles': entity_profiles,
'trinkets': trinkets,
}
finally:
zf.close()
def read_animation_manifest(path):
"""Build the animation manifest for a NoN animation pack.