Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
177 lines
6.5 KiB
Python
177 lines
6.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Клиент API Modrinth (https://api.modrinth.com/v2) — без ключей и библиотек.
|
|
|
|
Поиск модов, получение версий проекта и скачивание файлов.
|
|
Списки игнорируются через обычный http (умеет прокси и зеркала).
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import urllib.parse
|
|
|
|
from . import http
|
|
|
|
BASE = "https://api.modrinth.com/v2"
|
|
|
|
_cache = {} # url -> (время, данные)
|
|
|
|
# slugs официальных проектов
|
|
FABRIC_API_SLUG = "fabric-api"
|
|
QUILTED_API_SLUG = "quilted-fabric-api"
|
|
|
|
|
|
def _get_json(url, ttl=300):
|
|
hit = _cache.get(url)
|
|
if hit and time.time() - hit[0] < ttl:
|
|
return hit[1]
|
|
data = json.loads(http.get_bytes(url, timeout=30).decode("utf-8"))
|
|
_cache[url] = (time.time(), data)
|
|
return data
|
|
|
|
|
|
def search(query, game_version="", loader="", limit=20, offset=0):
|
|
"""Поиск модов. Возвращает (список, всего_найдено)."""
|
|
facets = [["project_type:mod"]]
|
|
if game_version:
|
|
facets.append(["versions:" + game_version])
|
|
if loader:
|
|
facets.append(["categories:" + loader])
|
|
params = {
|
|
"query": query or "",
|
|
"facets": json.dumps(facets, separators=(",", ":")),
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"index": "relevance" if query else "downloads",
|
|
}
|
|
url = BASE + "/search?" + urllib.parse.urlencode(params)
|
|
data = _get_json(url, ttl=300)
|
|
out = []
|
|
for h in data.get("hits") or []:
|
|
out.append({
|
|
"id": h.get("project_id") or "",
|
|
"title": h.get("title") or h.get("slug") or "?",
|
|
"slug": h.get("slug") or "",
|
|
"desc": (h.get("description") or "").strip(),
|
|
"downloads": int(h.get("downloads") or 0),
|
|
"follows": int(h.get("follows") or 0),
|
|
"icon": h.get("icon_url") or "",
|
|
"author": h.get("author") or "",
|
|
"categories": h.get("categories") or [],
|
|
})
|
|
return out, int(data.get("total_hits") or 0)
|
|
|
|
|
|
def project(pid):
|
|
"""Карточка проекта: заголовок, описание."""
|
|
d = _get_json(BASE + "/project/" + str(pid), ttl=600)
|
|
return {
|
|
"id": d.get("id") or "",
|
|
"title": d.get("title") or "?",
|
|
"desc": (d.get("description") or "").strip(),
|
|
"body": d.get("body") or "",
|
|
"downloads": int(d.get("downloads") or 0),
|
|
"icon": d.get("icon_url") or "",
|
|
}
|
|
|
|
|
|
def versions(pid, game_version="", loader=""):
|
|
"""Версии проекта под версию игры/загрузчик (свежие первыми)."""
|
|
url = BASE + "/project/" + str(pid) + "/version"
|
|
qs = []
|
|
if game_version:
|
|
qs.append("game_versions=" + urllib.parse.quote(
|
|
json.dumps([game_version])))
|
|
if loader:
|
|
qs.append("loaders=" + urllib.parse.quote(json.dumps([loader])))
|
|
if qs:
|
|
url += "?" + "&".join(qs)
|
|
raw = _get_json(url, ttl=300)
|
|
out = []
|
|
for v in raw if isinstance(raw, list) else []:
|
|
files = v.get("files") or []
|
|
if not files:
|
|
continue
|
|
primary = next((f for f in files if f.get("primary")), files[0])
|
|
hashes = primary.get("hashes") or {}
|
|
out.append({
|
|
"id": v.get("id") or "",
|
|
"name": (v.get("name") or v.get("version_number") or "?").strip(),
|
|
"number": (v.get("version_number") or "").strip(),
|
|
"date": (v.get("date_published") or "")[:10],
|
|
"url": primary.get("url") or "",
|
|
"filename": primary.get("filename") or "",
|
|
"size": int(primary.get("size") or 0),
|
|
"sha1": hashes.get("sha1") or "",
|
|
})
|
|
return out
|
|
|
|
|
|
def download_mod(file_url, dest, size=0, progress=None, cancel=None):
|
|
"""Скачивает файл мода в dest. True при успехе."""
|
|
return http.download_file(file_url, dest, size=size,
|
|
progress=progress, cancel=cancel)
|
|
|
|
|
|
def lookup_files(hashes, algorithm="sha1", batch=100):
|
|
"""Определяет версии Modrinth по хэшам файлов.
|
|
|
|
POST /version_files: {хэш: версия, в которой лежит этот файл}.
|
|
"""
|
|
out = {}
|
|
lst = [h for h in hashes if h]
|
|
for i in range(0, len(lst), batch):
|
|
data = http.post_json(BASE + "/version_files",
|
|
{"hashes": lst[i:i + batch],
|
|
"algorithm": algorithm}, timeout=30)
|
|
if isinstance(data, dict):
|
|
out.update({k: v for k, v in data.items() if isinstance(v, dict)})
|
|
return out
|
|
|
|
|
|
def _find_installed(mods_dir, prefixes):
|
|
"""Есть ли уже мод с одним из префиксов в имени файла."""
|
|
import os
|
|
if not os.path.isdir(mods_dir):
|
|
return False
|
|
low = [p.lower() for p in prefixes]
|
|
for name in os.listdir(mods_dir):
|
|
lname = name.lower()
|
|
if lname.endswith(".jar") and any(lname.startswith(p) for p in low):
|
|
return True
|
|
return False
|
|
|
|
|
|
def ensure_loader_api(game_dir, game_version, loader, log=None):
|
|
"""Ставит Fabric API / Quilted Fabric API, если модов нет.
|
|
|
|
Возвращает число скачанных файлов (0 — уже стоит или недоступно).
|
|
"""
|
|
import os
|
|
if loader not in ("fabric", "quilt"):
|
|
return 0
|
|
mods_dir = os.path.join(game_dir, "mods")
|
|
os.makedirs(mods_dir, exist_ok=True)
|
|
slug = FABRIC_API_SLUG if loader == "fabric" else QUILTED_API_SLUG
|
|
prefixes = ["fabric-api"] if loader == "fabric" else ["quilted-fabric-api"]
|
|
if _find_installed(mods_dir, prefixes):
|
|
return 0
|
|
if log:
|
|
log(f"Modrinth: ставлю {'Fabric API' if loader == 'fabric' else 'Quilted Fabric API'}…")
|
|
try:
|
|
pid = slug # API принимает и slug
|
|
vs = versions(pid, game_version, loader)
|
|
if not vs:
|
|
if log:
|
|
log(f"Modrinth: нет сборки {slug} для {game_version}/{loader}")
|
|
return 0
|
|
v = vs[0]
|
|
dest = os.path.join(mods_dir, v["filename"])
|
|
ok = download_mod(v["url"], dest, size=v["size"])
|
|
if ok and log:
|
|
log(f"Modrinth: {v['filename']} установлен")
|
|
return 1 if ok else 0
|
|
except Exception as e: # noqa: BLE001 — сеть/JSON не должны ронять лаунчер
|
|
if log:
|
|
log(f"Modrinth: не удалось поставить API ({e})")
|
|
return 0
|