# -*- 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 search_modpacks(query, game_version="", loader="", limit=20, offset=0): """Поиск сборок Modrinth (project_type=modpack).""" facets = [["project_type:modpack"]] 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 h.get("project_id") or h.get("slug") or "", "slug": h.get("slug") or "", "title": h.get("title") or h.get("slug") or "?", "desc": (h.get("description") or "").strip(), "downloads": int(h.get("downloads") or 0), "icon": h.get("icon_url") or "", "game_versions": h.get("versions") or [], "categories": h.get("categories") or [], }) return out, int(data.get("total_hits") or 0) def get_modpack_versions(pid, game_version="", loader=""): """Версии сборки Modrinth.""" 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 "", "game_versions": v.get("game_versions") or [], "loaders": v.get("loaders") or [], }) return out def download_pack(file_url, dest, size=0, progress=None, cancel=None): return http.download_file(file_url, dest, size=size, progress=progress, cancel=cancel)