"""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 io import json import os import re import struct import zipfile INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'} PROBLEM_TAGS = {'broken', 'bugged', 'borked'} # Blockbench-compatible model formats accepted by the explicit Model category. MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb') def _png_size(head): """(width, height) from a PNG IHDR header, or None when not a PNG.""" if len(head) < 24 or head[:8] != b'\x89PNG\r\n\x1a\n': return None return (struct.unpack('>I', head[16:20])[0], struct.unpack('>I', head[20:24])[0]) SKIN_SIZES = {(64, 32), (64, 64), (128, 128)} def read_skins_manifest(path): """Scan the archive for Minecraft skin PNGs (64x32, 64x64 or 128x128). Returns None when no skins are found; otherwise {skins: [{name, member, width, height}]} where ``member`` is the zip member served via pack_asset. """ zf = _open_zip(path) if zf is None: return None try: skins = [] for name in sorted(n for n in set(zf.namelist()) if n.lower().endswith('.png')): try: with zf.open(name) as fh: head = fh.read(24) except (KeyError, OSError, zipfile.BadZipFile): continue size = _png_size(head) if size and size in SKIN_SIZES: skins.append({ 'name': name.rsplit('/', 1)[-1], 'member': name, 'width': size[0], 'height': size[1], }) if not skins: return None return {'skins': skins} finally: zf.close() def _open_zip_any(source): """Open a zip from a path or raw bytes.""" if isinstance(source, (bytes, bytearray)): return zipfile.ZipFile(io.BytesIO(source)) return _open_zip(source) def _mc_from_filename(filename): """Best-effort MC version from a mod filename like 'x-1.0+mc26.2.jar'.""" m = re.search(r'mc(\d+(?:\.\d+)*)', str(filename).lower()) return m.group(1) if m else '' def _manifest_value(raw, key): for line in raw.splitlines(): if line.startswith(key + ':'): return line.split(':', 1)[1].strip() return '' def read_mod_manifest(source): """Parse a single Minecraft mod jar (path or bytes) into metadata or None. Loader detection order: fabric.mod.json → quilt.mod.json → META-INF/neoforge.mods.toml → META-INF/mods.toml → META-INF/MANIFEST.MF. """ zf = _open_zip_any(source) if zf is None: return None try: names = set(zf.namelist()) if 'fabric.mod.json' in names: data = _decode(zf.read('fabric.mod.json')) depends = data.get('depends') or {} deps = [ {'modId': k, 'type': 'required', 'versionRange': v} for k, v in depends.items() if k and v ] return { 'id': data.get('id') or '', 'name': data.get('name') or data.get('id') or '', 'version': data.get('version') or '', 'modloader': 'fabric', 'description': data.get('description') or '', 'environment': data.get('environment') or '', 'minecraft': depends.get('minecraft') or '', 'dependencies': deps, } if 'quilt.mod.json' in names: data = _decode(zf.read('quilt.mod.json')) ql = data.get('quilt_loader') or {} meta = ql.get('metadata') or {} deps = [] for d in ql.get('depends') or []: deps.append({ 'modId': d.get('id') or '', 'type': d.get('reason') or 'required', 'versionRange': d.get('versions') or '', }) minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '') return { 'id': ql.get('id') or '', 'name': meta.get('name') or ql.get('id') or '', 'version': ql.get('version') or '', 'modloader': 'quilt', 'description': meta.get('description') or '', 'environment': '', 'minecraft': minecraft, 'dependencies': deps, } for member, loader in (('META-INF/neoforge.mods.toml', 'neoforge'), ('META-INF/mods.toml', 'forge')): if member in names: parsed = _parse_mods_toml(zf.read(member).decode('utf-8', 'replace'), loader) if parsed: return parsed if 'META-INF/MANIFEST.MF' in names: raw = zf.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace') title = _manifest_value(raw, 'Implementation-Title') version = _manifest_value(raw, 'Implementation-Version') if title or version: return { 'id': title or '', 'name': title or 'unknown', 'version': version or '', 'modloader': 'unknown', 'description': '', 'environment': '', 'minecraft': '', 'dependencies': [], } return None finally: zf.close() def _parse_mods_toml(text, loader): """Parse a Forge/NeoForge mods.toml / neoforge.mods.toml.""" try: import tomllib data = tomllib.loads(text) except Exception: return None mods = data.get('mods') or [] if not mods: return None m = mods[0] mod_id = m.get('modId') or '' deps = [] for d in (data.get('dependencies') or {}).get(mod_id) or []: deps.append({ 'modId': d.get('modId') or '', 'type': d.get('type') or 'required', 'versionRange': d.get('versionRange') or '', }) minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '') return { 'id': mod_id, 'name': m.get('displayName') or mod_id, 'version': m.get('version') or '', 'modloader': loader, 'description': m.get('description') or '', 'environment': '', 'minecraft': minecraft, 'dependencies': deps, } def read_mod_version_manifest(file_entries): """Build the Mods manifest for a mod-category version. file_entries: list of (filename, path). Accepts standalone .jar files and .zip archives whose root holds .jar members (subfolders are ignored). Returns {mods: [...], no_mods: bool}. """ mods = [] for filename, path in file_entries: lower = filename.lower() if lower.endswith('.jar'): meta = read_mod_manifest(path) if meta: if not meta.get('minecraft'): meta['minecraft'] = _mc_from_filename(filename) meta['file_name'] = filename meta['member'] = '' mods.append(meta) elif lower.endswith('.zip'): zf = _open_zip(path) if zf is None: continue try: root_jars = sorted(n for n in zf.namelist() if n.lower().endswith('.jar') and '/' not in n) for member in root_jars: meta = read_mod_manifest(zf.read(member)) if meta: if not meta.get('minecraft'): meta['minecraft'] = _mc_from_filename(member) meta['file_name'] = member meta['member'] = member mods.append(meta) finally: zf.close() return {'mods': mods, 'no_mods': len(mods) == 0} def _in_plugins_dir(name): """True when a member lives inside a plugins/ folder (any nesting).""" parts = name.split('/') return 'plugins' in parts[:-1] def read_plugins_manifest(path): """Scan the archive's plugins/ folder for Blockbench plugins. A ``.js`` file under a ``plugins/`` directory that registers a Blockbench plugin (contains ``Plugin.register(`` or ``BBPlugin.register(``) is listed. ``plugins/autoload.txt`` holds one plugin name per line; matching plugins are marked ``autoload`` so the viewer can hotload them when a model opens. Returns None when the pack has no plugins. """ zf = _open_zip(path) if zf is None: return None try: names = set(zf.namelist()) autoload = set() for n in names: if _in_plugins_dir(n) and n.endswith('autoload.txt'): try: raw = zf.read(n).decode('utf-8', 'replace') except KeyError: continue for line in raw.splitlines(): line = line.strip() if line and not line.startswith('#'): autoload.add(line) plugins = [] for n in sorted(names): if not _in_plugins_dir(n) or not n.endswith('.js'): continue try: content = zf.read(n) except KeyError: continue if len(content) > 8 * 1024 * 1024: continue text = content.decode('utf-8', 'replace') if 'Plugin.register(' not in text and 'BBPlugin.register(' not in text: continue base = n.rsplit('/', 1)[-1] stem = base[:-3] if base.lower().endswith('.js') else base plugins.append({ 'name': base, 'member': n, 'size': len(content), 'autoload': base in autoload or stem in autoload, }) if not plugins: return None return { 'plugins': plugins, 'has_plugins': True, 'autoload_count': sum(1 for p in plugins if p['autoload']), } finally: zf.close() 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, category='non_pack'): """Build the Models/Textures manifest. For the explicit ``model`` category this scans the archive for any Blockbench-compatible model file (``MODEL_FILE_EXTENSIONS``) anywhere in the zip. For other categories it keeps the NoN GeckoLib geo-model detection (``geckolib/models/*.geo.json``). Returns None when no models are found. Each model records its filename, zip member, format, bone/cube counts, and the afw_bone_textures map (bone → texture zip member). """ zf = _open_zip(path) if zf is None: return None try: names = set(zf.namelist()) if category == 'model': model_names = sorted( n for n in names if not n.endswith('/') and n.lower().endswith(MODEL_FILE_EXTENSIONS) ) else: model_names = sorted( n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json') ) if not model_names: return None models = [] for name in model_names: ext = _model_ext(name) data = None try: data = _decode(zf.read(name)) except (KeyError, json.JSONDecodeError, UnicodeDecodeError): data = None bone_textures = {} bone_count = 0 cube_count = 0 identifier = '' if data and isinstance(data, dict): 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 if ext == '.geo.json': 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 []) elif ext == '.jem': bone_count = len(data.get('models') or []) entity = _entity_name(name) 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, 'ext': ext, 'mode': 'pack', 'identifier': identifier, 'entity': entity, 'default_texture': default_texture, 'bones': bone_count, 'cubes': cube_count, 'has_embedded_texture': ext in ('.bbmodel', '.gltf', '.glb'), '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 _model_ext(name): """The matching MODEL_FILE_EXTENSIONS suffix for a member, or ''.""" lower = name.lower() for ext in MODEL_FILE_EXTENSIONS: if lower.endswith(ext): return ext return '' def _entity_name(name): """Derive the entity name from a model filename (strip ext + gender suffix).""" base = name.rsplit('/', 1)[-1] for ext in MODEL_FILE_EXTENSIONS: if base.lower().endswith(ext): base = base[: -len(ext)] break for suffix in ('.mf', '.fm', '.m', '.f', '.g'): if base.endswith(suffix): base = base[:-len(suffix)] break return base 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 _loader_from_filename(filename): """Best-effort loader from a mod filename ('x_FABRIC_1.0.jar' → 'fabric').""" low = str(filename).lower() for token in ('fabricloader', 'fabric', 'neoforge', 'forge', 'quilt', 'rift', 'liteloader'): if token in low: return _normalize_modloader(token) return '' def _modpack_loaders(zf, members, limit=12): """Unique modloaders inferred for a modpack. Folder/instance archives embed their jars, so we parse the first few for their metadata. Modrinth .mrpack files only carry config overrides, so we fall back to the loader encoded in the mod filenames. A sample is enough because a modpack almost always targets a single loader. """ loaders = [] for member in members[:limit]: loader = '' if str(member).lower().endswith('.jar'): try: meta = read_mod_manifest(zf.read(member)) except Exception: meta = None loader = (meta or {}).get('modloader') or '' if not loader: loader = _loader_from_filename(member) if loader and loader not in loaders: loaders.append(loader) return loaders 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')), }) modloaders = _modpack_loaders(zf, [f['file_name'] for f in files]) return _modpack_result('modrinth', '', modloaders, 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 # Full instance = .minecraft/mods or a nested /minecraft/mods; # a root-level minecraft/mods folder is the classic folder layout. if '.minecraft' in parts or (('minecraft' in parts) and parts.index('minecraft') > 0): 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: modloaders = _modpack_loaders(zf, [m['file_name'] for m in mods]) return _modpack_result('instance' if is_instance else 'folder', '', modloaders, 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), }