"""Скачивание файлов игры: client jar, библиотеки, нативы, ассеты.""" import json import os import shutil import zipfile from concurrent.futures import as_completed, ThreadPoolExecutor from . import http LIB_WORKERS = 16 ASSET_WORKERS = 32 ASSET_BASE = "https://resources.download.minecraft.net/" def _mirror_urls(mirrors, default_base, url): """Кандидаты для скачивания: зеркала (если подходят) + официальный URL.""" urls = [] for m in mirrors or []: m = (m or "").strip().rstrip("/") if m and url.startswith(default_base): urls.append(m + "/" + url[len(default_base):]) urls.append(url) return urls def _download_many(tasks, workers=LIB_WORKERS, progress=None, verify=False, cancel=None): """tasks: список (urls, path, sha1, size) — urls по приоритету (зеркала). progress(done, total). verify=True — полная проверка SHA-1. cancel — threading.Event: досрочная оставка (http.Cancelled). """ total = len(tasks) errors = [] if not tasks: return errors def work(task): urls, path, sha1, size = task last = None for url in urls: if cancel is not None and cancel.is_set(): raise http.Cancelled() try: return http.download_file(url, path, sha1, size, verify, cancel=cancel) except http.Cancelled: raise except Exception as e: # noqa: BLE001 last = e return RuntimeError(f"{urls[-1] if urls else '?'}: {last}") with ThreadPoolExecutor(max_workers=workers) as ex: futs = [ex.submit(work, t) for t in tasks] done = 0 for fut in as_completed(futs): if cancel is not None and cancel.is_set(): for f2 in futs: f2.cancel() raise http.Cancelled() done += 1 try: res = fut.result() if isinstance(res, Exception): errors.append(str(res)) except http.Cancelled: raise except Exception as e: # noqa: BLE001 errors.append(str(e)) if progress: progress(done, total) return errors def extract_natives(version, libs_dir): """Распаковывает natives-jar'ы в папку нативов версии. Если набор jar'ов не менялся (сигнатура в .stamp), распаковка пропускается — на повторных запусках это экономит время. """ dest = version.natives_dir() os.makedirs(dest, exist_ok=True) stamp_path = os.path.join(dest, ".stamp") jars, sig_parts = [], [] for rel in version.natives_jars: jar = os.path.join(libs_dir, *rel.split("/")) if not os.path.isfile(jar): continue jars.append(jar) st = os.stat(jar) sig_parts.append(f"{rel}:{st.st_size}:{int(st.st_mtime)}") sig = ";".join(sig_parts) try: with open(stamp_path, encoding="utf-8") as f: if f.read() == sig: return dest except OSError: pass for jar in jars: try: with zipfile.ZipFile(jar) as z: for info in z.infolist(): name = info.filename if name.endswith("/") or name.upper().startswith("META-INF/"): continue target = os.path.join(dest, *name.split("/")) os.makedirs(os.path.dirname(target), exist_ok=True) with z.open(info) as src, open(target, "wb") as dst: shutil.copyfileobj(src, dst) except (zipfile.BadZipFile, OSError): continue try: with open(stamp_path, "w", encoding="utf-8") as f: f.write(sig) except OSError: pass return dest def _ensure_assets(version, progress=None, verify=False, cancel=None, mirrors=None): ai = version.asset_index if not ai.get("url"): return assets = version.assets_dir() idx_id = ai.get("id", "legacy") idx_path = os.path.join(assets, "indexes", idx_id + ".json") http.download_file(ai["url"], idx_path, ai.get("sha1"), ai.get("size"), cancel=cancel) with open(idx_path, encoding="utf-8") as f: index = json.load(f) base = ASSET_BASE tasks = [] objects = index.get("objects") or {} for name, obj in objects.items(): h = obj["hash"] url = base + h[:2] + "/" + h path = os.path.join(assets, "objects", h[:2], h) tasks.append((_mirror_urls(mirrors, base, url), path, h, obj.get("size"))) errors = _download_many(tasks, workers=ASSET_WORKERS, progress=progress, verify=verify, cancel=cancel) # Виртуальные ассеты для старых версий (1.6 и раньше: virtual / map_to_resources) if index.get("virtual"): vdir = os.path.join(assets, "virtual", idx_id) for name, obj in objects.items(): src = os.path.join(assets, "objects", obj["hash"][:2], obj["hash"]) dst = os.path.join(vdir, *name.split("/")) os.makedirs(os.path.dirname(dst), exist_ok=True) if not os.path.isfile(dst): try: shutil.copy2(src, dst) except OSError: pass if index.get("map_to_resources"): rdir = os.path.join(version.game_dir, "resources") for name, obj in objects.items(): src = os.path.join(assets, "objects", obj["hash"][:2], obj["hash"]) dst = os.path.join(rdir, *name.split("/")) os.makedirs(os.path.dirname(dst), exist_ok=True) if not os.path.isfile(dst): try: shutil.copy2(src, dst) except OSError: pass return errors def ensure(version, progress=None, asset_progress=None, log=None, verify=False, cancel=None, asset_mirrors=None, lib_mirrors=None): """Скачивает всё необходимое для запуска. Возвращает (errors, natives_dir). verify=True — полная проверка SHA-1 уже скачанных файлов (иначе достаточно совпадения размера). cancel — threading.Event: досрочная остановка (бросает http.Cancelled). asset_mirrors / lib_mirrors — списки зеркал (пусто = официальные серверы). """ libs_dir = version.libraries_dir() errors = [] lib_base = "https://libraries.minecraft.net/" tasks = [] if version.client_url: tasks.append(([version.client_url], version.client_jar_path(), version.client_sha1, version.client_size)) for lib in version.libraries: path = os.path.join(libs_dir, *lib["path"].split("/")) tasks.append((_mirror_urls(lib_mirrors, lib_base, lib["url"]), path, lib.get("sha1"), lib.get("size"))) if log: log(f"Скачивание библиотек ({len(tasks)} шт.)...") errors += _download_many(tasks, progress=progress, verify=verify, cancel=cancel) natives = extract_natives(version, libs_dir) lf = version.logging.get("file") or {} if lf.get("url") and lf.get("id"): cfg_dir = os.path.join(version.assets_dir(), "log_configs") try: http.download_file(lf["url"], os.path.join(cfg_dir, lf["id"]), lf.get("sha1"), lf.get("size"), cancel=cancel) except http.Cancelled: raise except Exception as e: # noqa: BLE001 errors.append(f"log config: {e}") if version.asset_index.get("url"): if log: log("Скачивание ассетов...") errors += _ensure_assets(version, progress=asset_progress, verify=verify, cancel=cancel, mirrors=asset_mirrors) or [] return errors, natives