"""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 []) entity = name.rsplit('/', 1)[-1] if entity.endswith('.geo.json'): entity = entity[:-len('.geo.json')] for suffix in ('.mf', '.fm', '.m', '.f', '.g'): if entity.endswith(suffix): entity = entity[:-len(suffix)] break # A pack-provided full skin is the model's default texture when the # model has no per-bone textures of its own. default_texture = '' if not bone_textures and entity: for n in names: if n.endswith(f'/textures/entity/{entity}/{entity}.png') or n.endswith(f'/textures/entity/{entity}.png'): default_texture = n break models.append({ 'name': name.rsplit('/', 1)[-1], 'member': name, 'identifier': identifier, 'entity': entity, 'default_texture': default_texture, '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 _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 → 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 _modpack_result('unknown', '', [], []) 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 = {} 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 _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': 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 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 = [] seen = set() is_instance = False for name in sorted(names): 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 _modpack_result('instance' if is_instance else 'folder', '', [], mods) 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), }