Added downloading mods from Modrinth

This commit is contained in:
Kallum Jones
2022-08-03 15:00:36 +01:00
parent 7774386385
commit 765920f80c
14 changed files with 681 additions and 11 deletions

View File

@ -0,0 +1,19 @@
import { Command } from "commander";
import Subcommand from "./subcommand.js"
import ModManager from "../mod-manager.js";
import Mods from "../mods/mods.js";
export default class InstallCommand implements Subcommand {
registerCommand(program: Command): void {
program.command("install")
.description("Installs the provided mods")
.argument("<mods...>", "The mods to install")
.action((mods) => {
ModManager.execute(async () => {
for (const mod of mods) {
await Mods.install(mod);
}
})
});
}
}

View File

@ -1,6 +1,5 @@
import { Command } from "commander";
export default abstract class Subcommand {
abstract registerCommand(program: Command): void;
abstract execute(): void;
export default interface Subcommand {
registerCommand(program: Command): void;
}

View File

@ -0,0 +1,7 @@
export default class DownloadError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

View File

@ -0,0 +1,7 @@
export default class ModNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

8
src/io/download_task.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
declare global {
type DownloadTask = {
fileName: string,
url: string
}
}
export {}

21
src/io/file_downloder.ts Normal file
View File

@ -0,0 +1,21 @@
import path from "path";
import * as https from "https";
import {createWriteStream} from "fs";
import DownloadError from "../errors/download_error.js";
export default class FileDownloader {
private static readonly MODS_FOLDER_PATH: string = path.join("mods");
static downloadMod(task: DownloadTask): void {
https.get(task.url, res => {
const filePath = path.join(this.MODS_FOLDER_PATH, task.fileName);
const writeStream = createWriteStream(filePath);
res.pipe(writeStream);
writeStream.on("finish", () => writeStream.close());
writeStream.on('error', () => {
throw new DownloadError(`Failed to download ${task.fileName} from ${task.url}`)
})
})
}
}

View File

@ -1,25 +1,31 @@
#!/usr/bin/env node
import { Command } from "commander";
import InitCommand from "./commands/init_command.js";
import InstallCommand from "./commands/install_command.js";
import Subcommand from "./commands/subcommand.js";
import Initialiser from "./util/initialiser.js";
import PrintUtils from "./util/print_utils.js";
//import PrettyError from "pretty-error";
export default class ModManager {
private static program: Command = new Command();
private static subcommands: Array<Subcommand> = [
new InitCommand()
new InitCommand(),
new InstallCommand()
];
static init() {
//const pe = new PrettyError();
//pe.start();
this.program
.name('mod-manager')
.description('A package (mod) manager for Fabric Minecraft Servers');
this.subcommands.forEach(command => {
command.registerCommand(ModManager.program);
});
for (const command of this.subcommands) {
command.registerCommand(this.program);
}
this.program.parse();
}
@ -30,10 +36,9 @@ export default class ModManager {
} else {
PrintUtils.error("Mod Manager is not initialised");
}
}
}
}
ModManager.init();

51
src/mods/mods.ts Normal file
View File

@ -0,0 +1,51 @@
import path from "path";
import Initialiser from "../util/initialiser.js";
import PrintUtils from "../util/print_utils.js";
import ModrinthSource from "./sources/modrinth_source.js";
import ModSource from "./sources/mod_source.js";
import ModNotFoundError from "../errors/mod_not_found_error.js";
export default class Mods {
public static readonly MOD_FILE = "mods.json";
private static readonly MOD_SOURCES: Array<ModSource> = [
new ModrinthSource()
];
public static async install(mod: string): Promise<void> {
let success: boolean = false;
// Go through each mod source
for (const source of this.MOD_SOURCES) {
// If we have not yet successfully installed the queried mod
if (!success) {
PrintUtils.info(`Searching for ${mod}...`);
// Search for the mod
let id;
try {
id = await source.search(mod);
} catch (e) {
if (e instanceof ModNotFoundError) {
PrintUtils.info(`Mod not found on ${source.getName()}`);
}
}
// If a mod is found, install it
if (id != undefined) {
PrintUtils.info(`Installing ${mod}...`);
try {
await source.install(id);
PrintUtils.success(`Successfully installed ${mod}`);
} catch (e) {
PrintUtils.error(`An error occurred downloading ${mod} from ${source.getName()}`);
}
}
}
}
}
public static getModFilePath(): string {
return path.join(Initialiser.getModManagerFolderPath(), this.MOD_FILE);
}
}

View File

@ -0,0 +1,5 @@
export default interface ModSource {
search(query: string): Promise<string>;
install(id: string): Promise<void>;
getName(): string;
}

View File

@ -0,0 +1,163 @@
import ModSource from "./mod_source.js";
import "string-format"
import {format} from "util";
import MinecraftUtils from "../../util/minecraft_utils.js";
import axios from "axios";
import ModNotFoundError from "../../errors/mod_not_found_error.js";
import Util from "../../util/util.js";
import FileDownloader from "../../io/file_downloder.js";
import DownloadError from "../../errors/download_error.js";
export default class ModrinthSource implements ModSource {
private static readonly BASE_URL: string = "https://api.modrinth.com/v2";
private static readonly SEARCH_URL: string = ModrinthSource.BASE_URL + "/search";
private static readonly INSTALL_URL: string = ModrinthSource.BASE_URL + "/project/%s/version";
/**
* Searches Modrinth for the specified query
* Example shape of data returned by query:
* {
* "hits": [
* {
* "project_id": "gvQqBUqZ",
* "project_type": "mod",
* "slug": "lithium",
* "author": "jellysquid3",
* "title": "Lithium",
* "description": "No-compromises game logic/server optimization mod",
* "categories": [
* "optimization",
* "fabric"
* ],
* "display_categories": [
* "optimization",
* "fabric"
* ],
* "versions": [
* "1.16.2",
* "1.16.3",
* "1.16.4",
* "1.16.5",
* "1.17",
* "1.17.1",
* "1.18",
* "1.18.1",
* "1.18.2",
* "1.19",
* "1.19.1"
* ],
* "downloads": 223103,
* "follows": 1866,
* "icon_url": "https://cdn.modrinth.com/data/gvQqBUqZ/icon.png",
* "date_created": "2021-01-03T00:56:52.292581Z",
* "date_modified": "2022-07-29T22:18:05.703354Z",
* "latest_version": "1.19.1",
* "license": "lgpl-3",
* "client_side": "optional",
* "server_side": "optional",
* "gallery": []
* }
* ],
* "offset": 0,
* "limit": 1,
* "total_hits": 1
* }
* @param query the query to search for
* @throws ModNotFoundError if the query returns no results.
* @returns The mod id of the found mod
*/
async search(query: string): Promise<string> | never {
const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion();
const params = {
query: query,
limit: 1,
facets: format('[["categories:fabric"],["versions:%s"]]', mcVersion)
}
const response = await axios.get(ModrinthSource.SEARCH_URL, {params});
const results = await response.data.hits;
if (Util.isArrayEmpty(results)) {
throw new ModNotFoundError(`Mod ${query} could not be found on ${this.getName()}`);
}
return results[0].project_id;
}
/**
* Installs the mod with the provided mod id
* Example shape of data returned by query:
* [
* {
* "id": "ZRR9yqHD",
* "project_id": "gvQqBUqZ",
* "author_id": "uhPSqlnd",
* "featured": false,
* "name": "Lithium 0.8.3",
* "version_number": "mc1.19.1-0.8.3",
* "changelog": "Lithium 0.8.3 is the second release for 1.19.1! It includes a bugfix too!\n\n## Fixes\n- fix: update chunk serialization patch to new mappings\n\nYou can donate on patreon: https://www.patreon.com/2No2Name\n",
* "changelog_url": null,
* "date_published": "2022-07-29T22:18:09.072973Z",
* "downloads": 3592,
* "version_type": "release",
* "files": [
* {
* "hashes": {
* "sha1": "9ef9f10f62d4c19b736fe493f2a11d737fbe3d7c",
* "sha512": "a3b623b4c14f6ba46d1486ffb3d1ba3174e3317b419b2ddfdf7bb572244e706d2e0a37bdce169c94455bec00fd107530ba78d7e611162a632cc6950e6a625433"
* },
* "url": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/mc1.19.1-0.8.3/lithium-fabric-mc1.19.1-0.8.3.jar",
* "filename": "lithium-fabric-mc1.19.1-0.8.3.jar",
* "primary": true,
* "size": 476619
* }
* ],
* "dependencies": [],
* "game_versions": [
* "1.19.1"
* ],
* "loaders": [
* "fabric"
* ]
* }
* ]
* @param id the id of the mod
* @throws DownloadError if an error occurs when downloading
* @throws ModNotFoundError if there are no versions available for the current Minecraft Version
*/
async install(id: string): Promise<void> {
const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion();
const params = {
loaders: '["fabric"]',
game_versions: format('["%s"]', mcVersion)
}
const response = await axios.get(format(ModrinthSource.INSTALL_URL, id), {params});
const results = await response.data;
if (Util.isArrayEmpty(results)) {
throw new ModNotFoundError(`Mod with id ${id} has no available versions on ${this.getName()} for Minecraft version ${mcVersion}`);
}
const latestFile = results[0].files[0];
const task: DownloadTask = {
fileName: latestFile.filename,
url: latestFile.url
}
try {
FileDownloader.downloadMod(task)
} catch (e) {
throw new DownloadError(`An error occurred downloading mod with id ${id} from ${this.getName()}`)
}
}
getName(): string {
return "Modrinth";
}
}

View File

@ -0,0 +1,44 @@
import {readdirSync} from "fs";
import path from "path";
import axios from "axios";
export default class MinecraftUtils {
static async getCurrentMinecraftVersion(): Promise<string> {
// Get installed versions as strings
const installedVersions: Array<string> = readdirSync(this.getVersionsFolderPath(), {withFileTypes: true})
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
// Get a list of Minecraft Versions
const response = await axios.get("https://meta.fabricmc.net/v2/versions/game");
const data = await response.data;
const minecraftVersions: Array<string> = [];
for (const ele of data) {
minecraftVersions.push(ele.version);
}
// Find the latest version that is currently installed
let index = Number.MAX_VALUE;
for (let version of installedVersions) {
let currentIndex = minecraftVersions.indexOf(version);
// If this version, is newer than the previous newest, save it's index
if (currentIndex < index) {
index = currentIndex;
}
}
const latestVersion = minecraftVersions[index];
if (latestVersion == undefined) {
throw new Error("There are no Minecraft versions available in this server. Is this a valid server installation?");
}
return latestVersion;
}
static getVersionsFolderPath(): string {
return path.join("versions")
}
}

5
src/util/util.ts Normal file
View File

@ -0,0 +1,5 @@
export default class Util {
static isArrayEmpty(array: Array<any> | undefined): boolean {
return array === undefined || array.length == 0;
}
}