"""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_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()