Files
NoN-Site/nonpacks/library/zips.py
T
2026-08-04 01:46:58 -05:00

493 lines
17 KiB
Python

"""Zip introspection helpers for category-specific version metadata.
Parsed once at upload time and stored on the Version row, so the detail page
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 json
import zipfile
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
def _decode(data):
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
return json.loads(data.decode('utf-8-sig', 'replace'))
def _open_zip(path):
"""Return a ZipFile for the path (None when it's not a zip)."""
try:
return zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError):
return None
def read_pack_mcmeta(path):
"""Extract the NoN-relevant data from a datapack's pack.mcmeta.
Returns (pack_format, description) or (None, '').
"""
zf = _open_zip(path)
if zf is None:
return None, ''
try:
try:
data = _decode(zf.read('pack.mcmeta'))
except (KeyError, json.JSONDecodeError):
return None, ''
pack = data.get('pack') or {}
try:
pack_format = int(pack.get('pack_format'))
except (TypeError, ValueError):
pack_format = None
description = pack.get('description', '') or ''
if isinstance(description, (dict, list)):
description = json.dumps(description)
return pack_format, str(description)
finally:
zf.close()
def _friendly_entity(entity):
"""minecraft:zombie -> Zombie; needsofnature:horse_liquid_collector -> Horse Liquid Collector."""
name = entity.rsplit(':', 1)[-1]
return name.replace('_', ' ').title()
def _gender(actor_tags):
for tag in actor_tags or []:
if str(tag).startswith('gender.'):
return str(tag).split('.', 1)[1]
return None
def _summarize_animdef(filename, data):
stem = filename.rsplit('/', 1)[-1]
if stem.endswith('.json'):
stem = stem[:-5]
display = data.get('display_name') or stem.replace('_', ' ').title()
participants = len(data.get('actors') or [])
actors = []
has_entity = False
entity_names = []
for actor in data.get('actors') or []:
entity_types = actor.get('entity_types') or []
entity = 'player'
if entity_types:
non_players = [e for e in entity_types if e != 'minecraft:player']
if non_players:
entity = _friendly_entity(non_players[0])
has_entity = True
entity_names.append(entity)
injector = actor.get('injector')
inj = None
inj_name = None
if injector is True:
inj = 'injector'
inj_name = 'injector'
elif isinstance(injector, str) and injector:
inj = injector
inj_name = INJECTOR_NAMES.get(injector, injector)
actors.append({
'label': actor.get('label') or '',
'entity': entity,
'gender': _gender(actor.get('actor_tags')),
'activity': actor.get('activity') or '',
'injector': inj,
'injector_name': inj_name,
'receiver': bool(actor.get('receiver')),
})
content_tags = [t for t in data.get('content_tags') or []]
animation_tags = data.get('animation_tags') or []
problem_tags = sorted({
t for t in list(content_tags) + list(animation_tags)
if str(t).lower() in PROBLEM_TAGS
})
if participants == 1:
type_ = 'Solo'
elif has_entity:
type_ = 'Entity x Player'
elif participants == 2:
type_ = 'Pair'
elif participants == 3:
type_ = 'Threesome'
else:
type_ = f'Group of {participants}'
stages = []
for stage in data.get('stages') or []:
stages.append({
'stage': stage.get('stage'),
'loop': bool(stage.get('loop')),
'cycle_seconds': stage.get('cycle_seconds'),
'speed': stage.get('speed'),
'climax': bool(stage.get('non_peak') or stage.get('manual_peak')),
'use_stage': stage.get('use_stage'),
'joinable': bool(stage.get('allow_join', True)),
'escapable': bool(stage.get('escapable', True)),
})
return {
'name': display,
'content_tags': content_tags,
'animation_tags': list(animation_tags),
'type': type_,
'entity_names': entity_names,
'participants': participants,
'actors': actors,
'stages': stages,
'weight': data.get('weight'),
'block_requirements': bool(data.get('block_requirements')),
'water': bool(data.get('water')),
'problem_tags': problem_tags,
}
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.
Detected by the presence of data/*/afw_animdefs/*.json. Returns None when
the archive isn't an animation pack. Reads the animationframework block
(id/name/version/authors/description) plus a summary of every animdef.
"""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
animdef_names = sorted(
n for n in names if '/afw_animdefs/' in n and n.endswith('.json')
)
if not animdef_names:
return None
animation_id = None
pack_name = ''
pack_version = ''
description = ''
authors = []
try:
mcmeta = _decode(zf.read('pack.mcmeta'))
af = mcmeta.get('animationframework') or {}
animation_id = af.get('id') or None
pack_name = str(af.get('name') or '')
pack_version = str(af.get('version') or '')
description = str(af.get('description') or '')
author = af.get('author')
if author:
authors = [a.strip() for a in str(author).split(',') if a.strip()]
except (KeyError, json.JSONDecodeError):
pass
animations = []
distinct_content_tags = set()
for name in animdef_names:
try:
data = _decode(zf.read(name))
except (KeyError, json.JSONDecodeError):
continue
summary = _summarize_animdef(name, data)
distinct_content_tags.update(summary['content_tags'])
animations.append(summary)
return {
'animation_id': animation_id,
'name': pack_name,
'version': pack_version,
'authors': authors,
'description': description,
'animations': animations,
'content_tags': sorted(distinct_content_tags),
}
finally:
zf.close()
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: [...]}.
"""
zf = _open_zip(path)
if zf is None:
return {'source': 'unknown', 'files': []}
try:
names = set(zf.namelist())
if filename.lower().endswith('.mrpack') or 'modrinth.index.json' in names:
try:
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', ''),
})
return {'source': 'modrinth', 'files': deps}
if 'manifest.json' in names:
try:
manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace'))
except (KeyError, json.JSONDecodeError):
manifest = {}
files = []
for entry in manifest.get('files') or []:
files.append({
'name': entry.get('fileName', entry.get('projectID', 'unknown')),
'project_id': entry.get('projectID'),
'file_id': entry.get('fileID'),
})
if files:
return {'source': 'curseforge', 'files': files}
mods = []
prefix = 'minecraft/mods/'
for name in sorted(names):
if name.startswith(prefix) and not name.endswith('/'):
mods.append({'name': name[len(prefix):]})
if mods:
return {'source': 'folder', 'files': mods}
return {'source': 'unknown', 'files': []}
finally:
zf.close()