221 lines
8.3 KiB
Python
221 lines
8.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Установка модпаков CurseForge (zip с manifest.json).
|
||
|
||
Формат CurseForge (minecraftModpack): в архиве лежит manifest.json, где
|
||
указана версия Minecraft, загрузчик и список модов (projectID + fileID),
|
||
плюс папка overrides/ с готовыми файлами игры.
|
||
|
||
Старые сборки (minecraftInstance) содержат minecraftinstance.json — там
|
||
моды перечислены с готовыми downloadUrl. Поддерживаются оба формата.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import zipfile
|
||
|
||
from . import curseforge, forge, http, mods
|
||
from .java_fetch import _safe_join
|
||
|
||
LOADERS = ("fabric", "quilt", "forge", "neoforge")
|
||
|
||
|
||
def _split_loader(lid):
|
||
"""'forge-47.2.0' -> ('forge', '47.2.0')."""
|
||
lid = str(lid or "").strip()
|
||
if "-" in lid:
|
||
name, ver = lid.split("-", 1)
|
||
return name.lower(), ver
|
||
return lid.lower(), ""
|
||
|
||
|
||
def read_manifest(path):
|
||
"""Читает manifest.json. Возвращает (данные, mc, loader, loader_ver)."""
|
||
with zipfile.ZipFile(path) as z:
|
||
return _read_manifest(z)
|
||
|
||
|
||
def _read_manifest(zf):
|
||
"""То же, но из уже открытого архива."""
|
||
with zf.open("manifest.json") as f:
|
||
data = json.loads(f.read().decode("utf-8"))
|
||
minecraft = data.get("minecraft") or {}
|
||
mc = minecraft.get("version") or ""
|
||
loader, loader_ver = "", ""
|
||
for ml in (minecraft.get("modLoaders") or []):
|
||
name, ver = _split_loader(ml.get("id"))
|
||
if name not in LOADERS:
|
||
continue
|
||
if not loader or ml.get("primary"):
|
||
loader, loader_ver = name, ver
|
||
if ml.get("primary"):
|
||
break
|
||
if not mc:
|
||
raise RuntimeError("в паке не указана версия Minecraft")
|
||
return data, mc, loader, loader_ver
|
||
|
||
|
||
def _legacy_instance(zf):
|
||
"""Старый формат minecraftinstance.json -> (mods, mc, loader, loader_ver)."""
|
||
with zf.open("minecraftinstance.json") as f:
|
||
data = json.loads(f.read().decode("utf-8"))
|
||
mc = data.get("minecraftVersion") or ""
|
||
loader, loader_ver = "", ""
|
||
fv = data.get("forgeVersion") or ""
|
||
if fv:
|
||
loader, loader_ver = "forge", fv
|
||
mods_list = []
|
||
for m in (data.get("installedMods") or []) + (data.get("addons") or []):
|
||
url = m.get("downloadUrl") or ""
|
||
name = m.get("fileName") or ""
|
||
if not url or not name:
|
||
continue
|
||
sha1 = ""
|
||
for h in (m.get("hashes") or []):
|
||
if h.get("algo") == 1:
|
||
sha1 = h.get("value") or ""
|
||
mods_list.append({"url": url, "name": name, "sha1": sha1,
|
||
"size": int(m.get("fileSize") or 0)})
|
||
return data, mc, loader, loader_ver, mods_list
|
||
|
||
|
||
def _install_loader(game_dir, loader, loader_ver, mc, log):
|
||
"""Ставит загрузчик. Возвращает id версии или None."""
|
||
if not loader:
|
||
return None
|
||
if loader in ("fabric", "quilt"):
|
||
if log:
|
||
log("Пак: %s %s" % (loader, loader_ver or ""))
|
||
return mods.install(game_dir, loader, mc, loader_version=loader_ver or None)
|
||
if loader in ("forge", "neoforge"):
|
||
if log:
|
||
log("Пак: %s %s" % (loader, loader_ver or ""))
|
||
return forge.install(game_dir, loader, mc,
|
||
loader_version=loader_ver or None, log=log)
|
||
return None
|
||
|
||
|
||
def _extract_overrides(zf, game_dir, prefixes=("overrides/", "client-overrides/"),
|
||
log=None):
|
||
"""Распаковывает overrides/ в игровую папку. Возвращает число файлов."""
|
||
count = 0
|
||
for name in zf.namelist():
|
||
if name.endswith("/"):
|
||
continue
|
||
target = None
|
||
for prefix in prefixes:
|
||
if name.startswith(prefix):
|
||
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 zf.open(name) as src, open(dest, "wb") as out:
|
||
out.write(src.read())
|
||
count += 1
|
||
if log and count:
|
||
log("Пак: распаковано %d файлов overrides" % count)
|
||
return count
|
||
|
||
|
||
def install(game_dir, pack_path, log=None, progress=None, cancel=None):
|
||
"""Ставит сборку CurseForge в game_dir. Возвращает (id_версии, файлов)."""
|
||
log = log or (lambda m, t=None: None)
|
||
with zipfile.ZipFile(pack_path) as zf:
|
||
names = set(zf.namelist())
|
||
if "manifest.json" in names:
|
||
data, mc, loader, loader_ver = _read_manifest(zf)
|
||
mod_list = []
|
||
for entry in (data.get("files") or []):
|
||
if entry.get("required") is False:
|
||
continue
|
||
pid = entry.get("projectID")
|
||
fid = entry.get("fileID")
|
||
if not pid or not fid:
|
||
continue
|
||
mod_list.append({"project": pid, "file": fid})
|
||
elif "minecraftinstance.json" in names:
|
||
data, mc, loader, loader_ver, mod_list = _legacy_instance(zf)
|
||
else:
|
||
raise RuntimeError("в архиве нет manifest.json")
|
||
|
||
if not mc:
|
||
raise RuntimeError("в паке не указана версия Minecraft")
|
||
log("Пак: Minecraft %s%s" % (mc, (", %s %s" % (loader, loader_ver))
|
||
if loader else ""))
|
||
|
||
vid = _install_loader(game_dir, loader, loader_ver, mc, log)
|
||
if progress:
|
||
progress(0.15)
|
||
|
||
# моды
|
||
mods_dir = os.path.join(game_dir, "mods")
|
||
os.makedirs(mods_dir, exist_ok=True)
|
||
total = len(mod_list) or 1
|
||
got = 0
|
||
for i, m in enumerate(mod_list):
|
||
if cancel is not None and cancel.is_set():
|
||
raise http.Cancelled
|
||
if "url" in m: # старый формат
|
||
url, name, sha1, size = (m["url"], m["name"], m["sha1"],
|
||
m.get("size") or 0)
|
||
else:
|
||
info = curseforge.get_file(m["project"], m["file"])
|
||
url = info.get("download_url") or ""
|
||
name = info.get("file_name") or ""
|
||
sha1 = info.get("sha1") or ""
|
||
size = info.get("size") or 0
|
||
if not url or not name:
|
||
continue
|
||
name = os.path.basename(name.replace("\\", "/"))
|
||
dest = _safe_join(mods_dir, name)
|
||
log("Пак: %s" % name)
|
||
try:
|
||
http.download_file(url, dest, size=size or None, sha1=sha1 or None,
|
||
progress=None, cancel=cancel)
|
||
got += 1
|
||
except http.Cancelled:
|
||
raise
|
||
except Exception as e: # noqa: BLE001
|
||
log("Пак: не скачан %s (%s)" % (name, e), "warn")
|
||
if progress:
|
||
progress(0.15 + 0.75 * (i + 1) / total)
|
||
|
||
# overrides
|
||
prefixes = ("overrides/", "client-overrides/")
|
||
ov_dir = (data.get("overrides") or "overrides")
|
||
if ov_dir and ov_dir != "overrides":
|
||
prefixes = (ov_dir.rstrip("/") + "/",) + prefixes
|
||
_extract_overrides(zf, game_dir, prefixes, log=log)
|
||
|
||
if progress:
|
||
progress(0.97)
|
||
return vid, got
|
||
|
||
|
||
def install_from_url(game_dir, url, log=None, progress=None, cancel=None,
|
||
filename_hint="pack.zip"):
|
||
"""Скачивает сборку CurseForge по прямой ссылке и устанавливает."""
|
||
dest = os.path.join(tempfile.gettempdir(), "PixelLauncher",
|
||
os.path.basename(filename_hint.replace("\\", "/"))
|
||
or "pack.zip")
|
||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||
|
||
def prog(done_b, total_b):
|
||
if progress and total_b:
|
||
progress(min(0.15, (done_b / total_b) * 0.15))
|
||
|
||
http.download_file(url, dest, progress=prog, cancel=cancel)
|
||
|
||
def p2(f):
|
||
if progress:
|
||
progress(0.15 + f * 0.85)
|
||
|
||
vid, got = install(game_dir, dest, log=log, progress=p2, cancel=cancel)
|
||
try:
|
||
os.unlink(dest)
|
||
except OSError:
|
||
pass
|
||
return vid, got
|