# -*- coding: utf-8 -*- """Импорт модпаков .mrpack (формат Modrinth) и каталог модпаков. mrpack — zip с modrinth.index.json: индекс описывает версию Minecraft, загрузчик (fabric/quilt/forge/neoforge) и список файлов для скачивания; плюс каталоги overrides/ (client-overrides/) — готовые файлы папки игры. """ import json import os import zipfile from . import forge, http, mods from .java_fetch import _safe_join PACKS_BASE = "https://pixellauncher.ru/packs" CATALOG_URL = PACKS_BASE + "/catalog.json" def fetch_catalog(timeout=15): """Каталог модпаков с сайта: [{title, desc, url, mc, loader, size}, ...].""" raw = http.get_bytes(CATALOG_URL, timeout=timeout) data = json.loads(raw.decode("utf-8")) if not isinstance(data, list): raise ValueError("пустой каталог модпаков") return [x for x in data if isinstance(x, dict) and isinstance(x.get("url"), str) and x["url"]] def pack_url(entry): """Абсолютный URL пака: относительные пути тянутся с сайта.""" u = str(entry.get("url") or "").strip() if u.startswith(("http://", "https://")): return u return PACKS_BASE + "/" + u.lstrip("/") def read_index(path): """Читает индекс пака. Возвращает (индекс, mc_ver, loader, loader_ver).""" with zipfile.ZipFile(path) as z: with z.open("modrinth.index.json") as f: data = json.loads(f.read().decode("utf-8")) deps = data.get("dependencies") or {} mc = deps.get("minecraft") or "" loader, loader_ver = "", "" if deps.get("fabric-loader"): loader, loader_ver = "fabric", deps["fabric-loader"] elif deps.get("quilt-loader"): loader, loader_ver = "quilt", deps["quilt-loader"] elif deps.get("neoforge"): loader, loader_ver = "neoforge", deps["neoforge"] elif deps.get("forge"): loader, loader_ver = "forge", deps["forge"] if not mc: raise RuntimeError("в паке не указана версия Minecraft") return data, mc, loader, loader_ver def _primary_download(downloads): """Достаёт основной файл из "downloads". Формат в индексе Modrinth встречается в трёх видах: ["https://..."] — просто список ссылок, [{"hashes": {...}, "url": "...", ...}] — старый формат с объектами, {"primary": "https://...", "alternate": [...]} — новый формат. """ if isinstance(downloads, dict): downloads = ([downloads.get("primary")] + list(downloads.get("alternate") or [])) if not downloads: return None first = downloads[0] if isinstance(first, str): return {"url": first, "filename": "", "size": 0} primary = next((d for d in downloads if isinstance(d, dict) and d.get("primary")), first) if not isinstance(primary, dict): return None return primary def _files_from_index(index): """[(путь_относительно_игры, url, filename, size)] — клиентские файлы.""" out = [] for entry in index.get("files") or []: env = entry.get("env") or {} if env.get("client") == "unsupported": continue downloads = entry.get("downloads") or [] if not downloads: continue primary = _primary_download(downloads) if not primary: continue url = primary.get("url") or "" if not url: continue rel = (entry.get("path") or "").replace("\\", "/") if not rel or rel.endswith("/"): continue out.append((rel, url, primary.get("filename") or rel.rsplit("/", 1)[-1], int(primary.get("size") or entry.get("fileSize") or 0))) return out def install(game_dir, pack_path, log=None, progress=None, cancel=None): """Ставит пак в game_dir. Возвращает (id_версии, скачано_файлов). progress(fraction) и cancel — как у http.download_file. """ if log: log(f"Пак: читаю {os.path.basename(pack_path)}…") index, mc, loader, loader_ver = read_index(pack_path) files = _files_from_index(index) total = sum(max(1, f[3]) for f in files) or 1 done = 0 # лоадер vid = None if loader: if log: log(f"Пак: Minecraft {mc}, {loader} {loader_ver}") if loader in ("fabric", "quilt"): vid = mods.install(game_dir, loader, mc, loader_version=loader_ver) else: # forge / neoforge — официальным инсталлятором vid = forge.install(game_dir, loader, mc, loader_version=loader_ver or None, log=log) done += max(1, total // (len(files) + 2)) # скачивание файлов got = 0 for rel, url, filename, size in files: if cancel is not None and cancel.is_set(): raise http.Cancelled dest = _safe_join(game_dir, rel) os.makedirs(os.path.dirname(dest), exist_ok=True) if log: log(f"Пак: {rel}") if http.download_file(url, dest, size=size, cancel=cancel): got += 1 done += max(1, size or 1) if progress: progress(min(0.95, done / total)) # overrides ov = 0 with zipfile.ZipFile(pack_path) as z: for name in z.namelist(): target = None for prefix in ("overrides/", "client-overrides/"): if name.startswith(prefix) and not name.endswith("/"): target = name[len(prefix):] break if not target: continue dest = _safe_join(game_dir, target) os.makedirs(os.path.dirname(dest), exist_ok=True) with z.open(name) as src, open(dest, "wb") as out: out.write(src.read()) ov += 1 if log and ov: log(f"Пак: распаковано {ov} файлов overrides") return vid, got def install_from_url(game_dir, url, log=None, progress=None, cancel=None, filename_hint="pack.mrpack"): """Скачивает модпак по прямой ссылке и устанавливает.""" import tempfile dest = os.path.join(tempfile.gettempdir(), "PixelLauncher", filename_hint.replace("/", "_")) os.makedirs(os.path.dirname(dest), exist_ok=True) def prog(done_b, total_b): if progress and total_b: progress(min(0.4, (done_b / total_b) * 0.4)) http.download_file(url, dest, progress=prog, cancel=cancel) def p2(f): if progress: progress(0.4 + f * 0.6) vid, got = install(game_dir, dest, log=log, progress=p2, cancel=cancel) try: os.unlink(dest) except OSError: pass return vid, got