Files
PixelLauncher/launcher/modrinth.py
T
kosta2014p ec6f13ddcc PixelLauncher 2.1.0 — стабильная сборка
Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров
(Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление
модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
2026-10-18 22:44:19 +07:00

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