From e453f336a8e4b0b222b3582dc11ab01bb177e3d6 Mon Sep 17 00:00:00 2001 From: Radiquum Date: Tue, 6 May 2025 03:14:16 +0500 Subject: [PATCH] feat: add modrinth mod --- dev.py | 2 + gui/api/ENDPOINTS.ts | 40 +++++-- gui/app/pack/new/page.tsx | 8 -- gui/app/pack/page.tsx | 214 +++++++++++++++++++++++++++++++++++-- src/api/pack.py | 49 ++++++++- src/api/source/Modrinth.py | 98 +++++++++++++++++ src/api/source/__init__.py | 0 src/config.py | 8 ++ 8 files changed, 398 insertions(+), 21 deletions(-) create mode 100644 src/api/source/Modrinth.py create mode 100644 src/api/source/__init__.py diff --git a/dev.py b/dev.py index 41ada51..6942e87 100644 --- a/dev.py +++ b/dev.py @@ -7,6 +7,8 @@ if __name__ == "__main__": environment = os.environ.copy() environment["is_dev"] = "True" environment["NEXT_PUBLIC_API_URL"] = "http://127.0.0.1:5000/api" + environment["MODRINTH_UA"] = "radiquum/YAMPD (kentai.waah@gmail.com)" + environment["CURSEFORGE_API_KEY"] = "$2a$10$bL4bIL5pUWqfcO7KQtnMReakwtfHbNKh6v1uTpKlzhwoueEJQnPnm" # TODO: handle multiple package managers line npm(node), deno, yarn # TODO?: install node deps automatically diff --git a/gui/api/ENDPOINTS.ts b/gui/api/ENDPOINTS.ts index c6df4e5..14015b8 100644 --- a/gui/api/ENDPOINTS.ts +++ b/gui/api/ENDPOINTS.ts @@ -1,17 +1,22 @@ const API = process.env.NEXT_PUBLIC_API_URL || "/api"; type _PACK_ENDPOINT = { - getPack: string; - getPackImage: string; + getPack: string; + getPackImage: string; editPackImage: string; }; type _PACKS_ENDPOINT = { - getPacks: string; + getPacks: string; createPack: string; deletePack: string; }; +type _MOD_ENDPOINT = { + addMod: string; + deleteMod: string; +}; + export const PACK_ENDPOINT = (endpoint: keyof _PACK_ENDPOINT, id: string) => { if (!id) { console.error(`ENDPOINT "${endpoint}" REQUIRES A PACK ID`); @@ -19,8 +24,8 @@ export const PACK_ENDPOINT = (endpoint: keyof _PACK_ENDPOINT, id: string) => { } const _endpoints = { - getPack: `${API}/pack/${id}`, - getPackImage: `${API}/pack/${id}/image`, + getPack: `${API}/pack/${id}`, + getPackImage: `${API}/pack/${id}/image`, editPackImage: `${API}/pack/${id}/image/edit`, }; return _endpoints[endpoint]; @@ -37,9 +42,32 @@ export const PACKS_ENDPOINT = ( } const _endpoints = { - getPacks: `${API}/packs/all`, + getPacks: `${API}/packs/all`, createPack: `${API}/packs/new`, deletePack: `${API}/packs/${id}/delete`, }; return _endpoints[endpoint]; }; + +export const MOD_ENDPOINT = ( + endpoint: keyof _MOD_ENDPOINT, + id: string, + slug?: string | null +) => { + if (!id) { + console.error(`ENDPOINT "${endpoint}" REQUIRES A PACK ID`); + return ""; + } + + const requireSlug: string[] = ["deleteMod"]; + if (requireSlug.includes(endpoint) && !slug) { + console.error(`ENDPOINT "${endpoint}" REQUIRES A MOD SLUG`); + return ""; + } + + const _endpoints = { + addMod: `${API}/pack/${id}/mod/add`, + deleteMod: `${API}/pack/${id}/mod/${slug}/delete`, + }; + return _endpoints[endpoint]; +}; diff --git a/gui/app/pack/new/page.tsx b/gui/app/pack/new/page.tsx index ca77944..99c793b 100644 --- a/gui/app/pack/new/page.tsx +++ b/gui/app/pack/new/page.tsx @@ -86,14 +86,6 @@ export default function PackNew() { }); } - toast.update(tid, { - render: data.message, - type: "success", - isLoading: false, - autoClose: 2500, - closeOnClick: true, - draggable: true, - }); const ur = new URL(window.location.href); ur.searchParams.set("id", data.id); ur.pathname = "/pack"; diff --git a/gui/app/pack/page.tsx b/gui/app/pack/page.tsx index e770211..3a99b38 100644 --- a/gui/app/pack/page.tsx +++ b/gui/app/pack/page.tsx @@ -1,26 +1,55 @@ "use client"; -import { PACK_ENDPOINT, PACKS_ENDPOINT } from "@/api/ENDPOINTS"; +import { MOD_ENDPOINT, PACK_ENDPOINT, PACKS_ENDPOINT } from "@/api/ENDPOINTS"; import { Pack } from "@/types/pack"; -import { Button, Card, Spinner } from "flowbite-react"; +import { + Button, + Card, + Label, + Select, + Spinner, + TextInput, +} from "flowbite-react"; import { useSearchParams } from "next/navigation"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "flowbite-react"; -import { HiDownload, HiTrash } from "react-icons/hi"; +import { HiDownload, HiPlusCircle, HiTrash } from "react-icons/hi"; import { ModTable } from "../components/ModTable"; +import { toast } from "react-toastify"; +import { Mod } from "@/types/mod"; export default function PackPage() { const [packData, setPackData] = useState(null); + const [packMods, setPackMods] = useState([]); const [packDataLoading, setPackDataLoading] = useState(true); const router = useRouter(); const id = useSearchParams().get("id") || ""; + const [addModModalOpen, setAddModModalOpen] = useState(true); + const [modSource, setModSource] = useState<"Modrinth" | "CurseForge">( + "Modrinth" + ); + const [modUrl, setModUrl] = useState({ + placeholer: "https://modrinth.com/mod/{ slug }", + value: "", + }); + // const [modParams, setModParams] = useState<{ + // slug: string; + // version: string | null; + // }>({ + // slug: "", + // version: null, + // }); + useEffect(() => { async function _getPacksData() { const res = await fetch(PACK_ENDPOINT("getPack", id)); if (!res.ok) router.push("/404"); - setPackData(await res.json()); + const data: Pack = await res.json(); + setPackData(data); + setPackMods(data.mods); setPackDataLoading(false); } if (id) { @@ -67,6 +96,127 @@ export default function PackPage() { } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleModSource = (e: any) => { + switch (e.target.value) { + case "Modrinth": + setModSource("Modrinth"); + setModUrl({ + placeholer: "https://modrinth.com/mod/{ slug }", + value: "", + }); + break; + case "CurseForge": + setModSource("CurseForge"); + setModUrl({ + placeholer: + "https://www.curseforge.com/minecraft/mc-mods/{ slug }", + value: "", + }); + break; + } + }; + + async function addMod() { + let slug = null; + let version = null; + + if (!modUrl.value) { + toast.error("Mod url is required", { + autoClose: 2500, + closeOnClick: true, + draggable: true, + }); + return; + } + + switch (modSource) { + case "Modrinth": + const _tmp = modUrl.value.split("/mod/"); + if (_tmp.length == 1) { + toast.error("invalid Modrinth url", { + autoClose: 2500, + closeOnClick: true, + draggable: true, + }); + return; + } + const _tmp2 = _tmp[1].split("/version/"); + slug = _tmp2[0]; + if (_tmp2.length > 1) { + version = _tmp2[1]; + } + break; + case "CurseForge": + const _tmp3 = modUrl.value.split("/mc-mods/"); + if (_tmp3.length == 1) { + toast.error("invalid CurseForge url", { + autoClose: 2500, + closeOnClick: true, + draggable: true, + }); + return; + } + const _tmp4 = _tmp3[1].split("/files/"); + slug = _tmp4[0]; + if (_tmp4.length > 1) { + version = _tmp4[1]; + } + break; + } + + slug = slug.replace("/", ""); + version = version ? version.replace("/", "") : null; + + // if (packMods.find((elem) => elem.slug == slug)) { + // toast.error(`mod (${slug}) already exists`, { + // autoClose: 2500, + // closeOnClick: true, + // draggable: true, + // }); + // return; + // } + + if (!packData) return; + const tid = toast.loading(`Adding mod`); + const res = await fetch(MOD_ENDPOINT("addMod", packData._id), { + method: "POST", + body: JSON.stringify({ + slug, + version, + source: modSource, + }), + headers: { + "content-type": "application/json", + accept: "application/json", + }, + }); + const data = await res.json(); + + if (data.status != "ok") { + toast.update(tid, { + render: data.message, + type: "error", + isLoading: false, + autoClose: 2500, + closeOnClick: true, + draggable: true, + }); + return; + } + + setPackMods([...packMods, data.mod]); + toast.update(tid, { + render: data.message, + type: "success", + isLoading: false, + autoClose: 2500, + closeOnClick: true, + draggable: true, + }); + setModUrl({ ...modUrl, value: "" }) + } + return (
{packDataLoading && ( @@ -104,12 +254,15 @@ export default function PackPage() {
- +
@@ -118,6 +271,55 @@ export default function PackPage() { )} + setAddModModalOpen(false)} + > + Terms of Service + +
+
+
+ +
+ +
+
+
+ +
+ + setModUrl({ ...modUrl, value: e.target.value }) + } + value={modUrl.value} + placeholder={modUrl.placeholer} + required + /> +
+
+
+ + + +
); } diff --git a/src/api/pack.py b/src/api/pack.py index d24871e..5002308 100644 --- a/src/api/pack.py +++ b/src/api/pack.py @@ -7,12 +7,13 @@ from PIL import Image from io import BytesIO import base64 import json +from .source import Modrinth @apiPack.route("/", methods=["GET"]) def getPack(id): if not os.path.exists(f"{PACKS_FOLDER}/{id}/packfile.json"): - return jsonify({"status": "error", "message": "not found"}), 404 + return jsonify({"status": "error", "message": "pack not found"}), 404 pack = {} with open(f"{PACKS_FOLDER}/{id}/packfile.json") as fp: @@ -59,3 +60,49 @@ def editPackImage(id): "message": "image updated", } ) + + +@apiPack.route("//mod/add", methods=["POST"]) +def addMod(id): + source = request.json.get("source", None) + slug = request.json.get("slug", None) + version = request.json.get("version", None) + pack = {} + mod = {} + + if not os.path.exists(f"{PACKS_FOLDER}/{id}/packfile.json"): + return jsonify({"status": "error", "message": "pack not found"}) + with open(f"{PACKS_FOLDER}/{id}/packfile.json") as fp: + pack = json.load(fp) + fp.close() + mod_loader = pack.get("modloader").lower() + game_version = pack.get("version") + + if not source: + return jsonify({"status": "error", "message": "mod source is required"}) + if not slug: + return jsonify({"status": "error", "message": "mod slug is required"}) + + for mod in pack["mods"]: + if mod.get("slug") == slug: + return jsonify({"status": "error", "message": "mod already exists"}) + + if source == "Modrinth": + mod = Modrinth.getModrinthMod(slug, version, mod_loader, game_version) + + if mod.get("status") != "ok": + return jsonify({"status": "error", "message": mod.get("message")}) + + pack["modpackVersion"] += 1 + pack["mods"].append(mod.get("mod")) + with open(f"{PACKS_FOLDER}/{id}/packfile.json", mode="w", encoding="utf-8") as fp: + json.dump(pack, fp) + fp.close() + + return jsonify( + { + "status": "ok", + "message": f"mod {mod.get("mod").get('title')} ({slug}) has been added", + "mod": mod.get("mod"), + } + ) diff --git a/src/api/source/Modrinth.py b/src/api/source/Modrinth.py new file mode 100644 index 0000000..f0a270d --- /dev/null +++ b/src/api/source/Modrinth.py @@ -0,0 +1,98 @@ +import requests +from config import MODRINTH_UA + + +def getModrinthMod(slug, version, mod_loader, game_version): + headers = {"User-Agent": MODRINTH_UA} + descR = requests.get(f"https://api.modrinth.com/v2/project/{slug}", headers=headers) + + if descR.status_code != 200: + return { + "status": "error", + "message": f"failed to fetch modrinth description: {descR.status_code}", + } + + versR = requests.get( + f'https://api.modrinth.com/v2/project/{slug}/version?loaders=["{mod_loader}"]&game_versions=["{game_version}"]', + headers=headers, + ) + + if versR.status_code != 200: + return { + "status": "error", + "message": f"failed to fetch modrinth mod versions: {versR.status_code}", + } + + devsR = requests.get( + f"https://api.modrinth.com/v2/project/{slug}/members", + headers=headers, + ) + + if devsR.status_code != 200: + return { + "status": "error", + "message": f"failed to fetch modrinth mod developers: {devsR.status_code}", + } + + desc: dict = descR.json() + vers: dict = versR.json() + devs: dict = devsR.json() + + if len(vers) == 0: + return { + "status": "error", + "message": f"mod is not compatible with this game version or mod loader", + } + + selected_version = vers[0] + if version: + for _sf in vers: + if _sf.get("version_number") == version: + selected_version = _sf + break + + primary_file = None + for _pf in selected_version.get("files"): + if _pf.get("primary") == True: + primary_file = _pf + break + + if primary_file is None: + return { + "status": "error", + "message": f"failed to get primary mod file", + } + + developers = [] + for dev in devs: + developers.append(dev["user"]["username"]) + + isClient = False + if desc.get("client_side") in ["optional", "required"]: + isClient = True + isServer = False + if desc.get("server_side") in ["optional", "required"]: + isServer = True + + return { + "status": "ok", + "mod": { + "slug": slug, + "icon": desc.get("icon_url"), + "title": desc.get("title"), + "developers": developers, + "source": "Modrinth", + "url": f"https://modrinth.com/mod/{slug}", + "environment": { + "client": isClient, + "server": isServer, + }, + "file": { + "version": selected_version.get("version_number"), + "hashes": primary_file.get("hashes"), + "url": primary_file.get("url"), + "filename": primary_file.get("filename"), + "size": primary_file.get("size"), + }, + }, + } diff --git a/src/api/source/__init__.py b/src/api/source/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config.py b/src/config.py index acbc419..f54d5d7 100644 --- a/src/config.py +++ b/src/config.py @@ -5,3 +5,11 @@ if os.getenv("is_dev") == "True": PACKS_FOLDER = "../packs" IMG_ALLOWED_MIME = {"image/png", "image/jpg", "image/jpeg", "image/webp", "image/jfif"} + +MODRINTH_UA = None +if os.getenv("MODRINTH_UA"): + MODRINTH_UA = os.getenv("MODRINTH_UA") + +CURSEFORGE_API_KEY = None +if os.getenv("CURSEFORGE_API_KEY"): + CURSEFORGE_API_KEY = os.getenv("CURSEFORGE_API_KEY")