feat: add modrinth mod

This commit is contained in:
Kentai Radiquum 2025-05-06 03:14:16 +05:00
parent 237ce9879d
commit e453f336a8
Signed by: Radiquum
GPG key ID: 858E8EE696525EED
8 changed files with 398 additions and 21 deletions

2
dev.py
View file

@ -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

View file

@ -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];
};

View file

@ -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";

View file

@ -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<Pack | null>(null);
const [packMods, setPackMods] = useState<Mod[]>([]);
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 }</version/{ file_id }>",
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 }</version/{ file_id }>",
value: "",
});
break;
case "CurseForge":
setModSource("CurseForge");
setModUrl({
placeholer:
"https://www.curseforge.com/minecraft/mc-mods/{ slug }</files/{ file_id }>",
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 (
<div>
{packDataLoading && (
@ -104,12 +254,15 @@ export default function PackPage() {
</div>
</div>
<div className="flex gap-2">
<Button color={"red"} onClick={() => deletePack()}>
Delete <HiTrash className="ml-2 h-5 w-5" />
<Button onClick={() => setAddModModalOpen(true)}>
Add mod <HiPlusCircle className="ml-2 h-5 w-5" />
</Button>
<Button>
Download <HiDownload className="ml-2 h-5 w-5" />
</Button>
<Button color={"red"} onClick={() => deletePack()}>
Delete <HiTrash className="ml-2 h-5 w-5" />
</Button>
</div>
</div>
</Card>
@ -118,6 +271,55 @@ export default function PackPage() {
</div>
</div>
)}
<Modal
dismissible
show={addModModalOpen}
onClose={() => setAddModModalOpen(false)}
>
<ModalHeader>Terms of Service</ModalHeader>
<ModalBody>
<div className="space-y-6">
<div className="flex-1">
<div className="mb-2 block">
<Label htmlFor="base" className="text-lg">
Source
</Label>
</div>
<Select
id="source"
name="source"
required
onChange={(e) => handleModSource(e)}
>
<option value="Modrinth">Modrinth</option>
<option value="CurseForge">CurseForge</option>
</Select>
</div>
<div className="flex-1">
<div className="mb-2 block">
<Label htmlFor="base" className="text-lg">
Link
</Label>
</div>
<TextInput
id="base"
type="text"
sizing="md"
name="author"
onChange={(e) =>
setModUrl({ ...modUrl, value: e.target.value })
}
value={modUrl.value}
placeholder={modUrl.placeholer}
required
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onClick={() => addMod()}>Save</Button>
</ModalFooter>
</Modal>
</div>
);
}

View file

@ -7,12 +7,13 @@ from PIL import Image
from io import BytesIO
import base64
import json
from .source import Modrinth
@apiPack.route("/<id>", 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("/<id>/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"),
}
)

View file

@ -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"),
},
},
}

View file

View file

@ -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")