"""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 os 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 DEFAULT_PACK_BLOCK = { 'pack_format': 64, 'supported_formats': [64, 81], 'min_format': 64, 'max_format': 81, } def inject_animationframework(path, fields, create_pack_block=None): """Rewrite the zip at ``path`` in place so pack.mcmeta carries the animationframework block (id/name/author/version/description). When the archive already has pack.mcmeta, only the animationframework key is added/updated and every other field is preserved. When it has none, a whole pack.mcmeta is created (pack block from ``create_pack_block``, which defaults to the current NoN pack format). Returns True on success. """ tmp = f'{path}.nfnorm' try: with zipfile.ZipFile(path, 'r') as zin: names = set(zin.namelist()) if 'pack.mcmeta' in names: try: mcmeta = _decode(zin.read('pack.mcmeta')) except (KeyError, json.JSONDecodeError): mcmeta = {} if not isinstance(mcmeta.get('pack'), dict): mcmeta['pack'] = dict(create_pack_block or DEFAULT_PACK_BLOCK) else: block = dict(create_pack_block or DEFAULT_PACK_BLOCK) mcmeta = {'pack': block} mcmeta['animationframework'] = fields with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout: for item in zin.infolist(): if item.filename == 'pack.mcmeta': continue zout.writestr(item, zin.read(item.filename)) zout.writestr('pack.mcmeta', json.dumps(mcmeta, indent=2)) os.replace(tmp, path) return True except (OSError, zipfile.BadZipFile): try: if os.path.exists(tmp): os.remove(tmp) except OSError: pass return False 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/// or assets/// (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()