modded7/tools/utils/curseForgeAPI.ts
tracer4b b526677126 Questbook Update + Buildscript Improvements (#681)
[EXPAND]
[[messages]]
messageTitle = "QB Update for GT 2.8 (#681)"
messageBody = """
[QB]
[DETAILS]
details = ["Fixes many Quest Book issues", "Updates QB with changes in GT 2.8"]
[DETAILS]
"""

[[messages]]
messageTitle = "Buildscript Refactor (#681)"
messageBody = """
[INTERNAL]
[DETAILS]
details = ["**Important: Buildscript has changed from `npx gulp...` or `gulp...` to `npm run gulp...`**!", "Moves to Node 16 Package Management + Typescript Strict Mode", "New Port QB, Check QB and Fix QB Tasks"]
[DETAILS]
"""
[EXPAND]


Co-authored-by: Integer Limit <103940576+IntegerLimit@users.noreply.github.com>
Co-authored-by: Ghzdude <44148655+ghzdude@users.noreply.github.com>
Co-authored-by: SparkedTheorem <162088357+SparkedTheorem@users.noreply.github.com>
2024-05-14 21:57:00 +10:00

347 lines
9.1 KiB
TypeScript

import {
CurseForgeFileInfo,
CurseForgeModInfo,
CurseForgeModInfo as CurseForgeProject,
} from "#types/curseForge.ts";
import { ModpackManifestFile } from "#types/modpackManifest.ts";
import buildConfig from "#buildConfig";
import upath from "upath";
import fs from "fs";
import { FileDef } from "#types/fileDef.ts";
import {
downloadOrRetrieveFileDef,
getAxios,
RetrievedFileDefReason,
} from "./util.ts";
import logInfo, { logError, logWarn } from "./log.ts";
function getCurseForgeToken() {
const vari = "CFCORE_API_TOKEN";
const val = process.env[vari];
if (!process.env[vari]) {
throw new Error(`Environmental variable ${vari} is unset.`);
}
return val;
}
const curseForgeProjectCache: { [key: number]: CurseForgeProject } = {};
export async function fetchProject(
toFetch: number,
): Promise<CurseForgeProject> {
if (curseForgeProjectCache[toFetch]) {
return curseForgeProjectCache[toFetch];
}
const project: CurseForgeProject | undefined = (
await getAxios()({
url: `${buildConfig.cfCoreApiEndpoint}/v1/mods/${toFetch}`,
method: "get",
responseType: "json",
headers: {
"X-Api-Key": getCurseForgeToken(),
},
})
).data?.data;
if (!project) {
throw new Error(`Failed to fetch project ${toFetch}`);
}
curseForgeProjectCache[toFetch] = project;
return project;
}
const fetchedFileInfoCache: { [key: string]: CurseForgeFileInfo } = {};
export async function fetchFileInfo(
projectID: number,
fileID: number,
): Promise<CurseForgeFileInfo> {
const slug = `${projectID}/${fileID}`;
if (fetchedFileInfoCache[slug]) {
return fetchedFileInfoCache[slug];
}
const fileInfo: CurseForgeFileInfo = (
await getAxios()({
url: `${buildConfig.cfCoreApiEndpoint}/v1/mods/${projectID}/files/${fileID}`,
method: "get",
responseType: "json",
headers: {
"X-Api-Key": getCurseForgeToken(),
},
})
).data?.data;
if (!fileInfo) {
throw new Error(`Failed to download file ${projectID}/file/${fileID}`);
}
fetchedFileInfoCache[slug] = fileInfo;
return fileInfo;
}
export interface ProjectToFileId {
projectID: number;
fileID: number;
}
/**
* Fetches multiple CurseForge files.
* Falls back to fetchFileInfo in case it's impossible to bulk-fetch some files.
*
* @param toFetch List of Project IDs to File IDs, to fetch.
* @returns CurseForge file infos.
*/
export async function fetchFilesBulk(
toFetch: ProjectToFileId[],
): Promise<CurseForgeFileInfo[]> {
const fileInfos: CurseForgeFileInfo[] = [];
// Map of file ids not fetched (project ID to file ID)
const unfetched: ProjectToFileId[] = [];
// Determine projects that have been fetched already.
toFetch.forEach((file) => {
const slug = `${file.projectID}/${file.fileID}`;
const cached = fetchedFileInfoCache[slug];
if (cached) fileInfos.push(cached);
else unfetched.push(file);
});
// Sort list (reduces risk of duplicate entries)
unfetched.sort((a, b) => a.fileID - b.fileID);
if (unfetched.length > 0) {
// Augment the array of known files with new info.
const fetched: CurseForgeFileInfo[] = (
await getAxios()({
url: `${buildConfig.cfCoreApiEndpoint}/v1/mods/files`,
method: "post",
data: {
fileIds: unfetched.map((file) => file.fileID),
},
headers: {
"X-Api-Key": getCurseForgeToken(),
},
})
).data?.data;
if (!fetched) {
throw new Error(
`Failed to bulk-fetch files:\n${unfetched
.map((file) => `File ${file.fileID} of mod ${file.projectID},`)
.join("\n")}`,
);
}
// Remove duplicate entries (Batch Fetch sometimes returns duplicate inputs... for some reason)
if (fetched.length > unfetched.length) {
// Can't directly use Set, as Set compares object ref, not object data
const uniqueFileIDs: number[] = [];
fetched.forEach((file) => {
if (!uniqueFileIDs.includes(file.id)) {
fileInfos.push(file);
uniqueFileIDs.push(file.id);
}
});
} else {
fileInfos.push(...fetched);
}
// Cache fetched stuff.
fetched.forEach((info) => {
fetchedFileInfoCache[`${info.modId}/${info.id}`] = info;
});
// In case we haven't received the proper amount of mod infos,
// try requesting them individually.
if (fileInfos.length < toFetch.length) {
// Set of fetched fileIDs.
const fileInfoIDs: Set<number> = new Set(
fileInfos.map((file) => {
return file.id;
}),
);
const toFetchMissing = [
...new Set(toFetch.filter((x) => !fileInfoIDs.has(x.fileID))),
];
if (toFetchMissing.length > 0) {
logWarn(
`Couldn't fetch next project IDs in bulk:\n${toFetchMissing
.map((file) => `File ${file.fileID} of mod ${file.projectID},`)
.join("\n")}`,
);
// Try fetching files individually, in case they've been deleted.
let count = 0;
const missingFileInfos: Promise<CurseForgeFileInfo>[] = [];
for (const file of toFetchMissing) {
logInfo(
`Fetching file ${file.fileID} of mod ${file.projectID} directly... (${++count} / ${toFetchMissing.length})`,
);
try {
// In case something fails to download; catch, rewrite, rethrow.
missingFileInfos.push(fetchFileInfo(file.projectID, file.fileID));
} catch (err) {
logError(
`Couldn't fetch file ${file.fileID} of mod ${file.projectID}. See Below.`,
);
throw err;
}
}
// The code above is expected to throw and terminate the further execution,
// so we can just do this.
fileInfos.push(...(await Promise.all(missingFileInfos)));
}
}
}
return fileInfos;
}
/**
* Fetches multiple CurseForge projects.
* Falls back to fetchModInfo in case it's impossible to bulk-fetch some projects.
*
* @param toFetch Project IDs to fetch.
* @returns CurseForge project infos.
*/
export async function fetchProjectsBulk(
toFetch: number[],
): Promise<CurseForgeProject[]> {
const modInfos: CurseForgeProject[] = [];
const unfetched: number[] = [];
// Determine projects that have been fetched already.
toFetch.forEach((id) => {
const cached = curseForgeProjectCache[id];
if (cached) {
modInfos.push(cached);
} else {
unfetched.push(id);
}
});
if (unfetched.length > 0) {
// Augment the array of known projects with new info.
const fetched: CurseForgeProject[] = (
await getAxios()({
url: `${buildConfig.cfCoreApiEndpoint}/v1/mods`,
method: "post",
data: {
modIds: unfetched,
},
headers: {
"X-Api-Key": getCurseForgeToken(),
},
})
).data?.data;
if (!fetched) {
throw new Error(`Failed to bulk-fetch projects ${unfetched.join(", ")}`);
}
modInfos.push(...fetched);
// Cache fetched stuff.
fetched.forEach((mi) => {
curseForgeProjectCache[mi.id] = mi;
});
// In case we haven't received the proper amount of mod infos,
// try requesting them individually.
if (modInfos.length !== toFetch.length) {
const modInfoIDs = new Set(modInfos.map((mi) => mi.id));
const toFetchMissing = [
...new Set(toFetch.filter((x) => !modInfoIDs.has(x))),
];
logWarn(
`Couldn't fetch some project IDs in bulk: ${toFetchMissing.join(", ")}`,
);
// Try fetching mods individually, in case they've been deleted.
let count = 0;
const missingModInfos: Promise<CurseForgeModInfo>[] = [];
for (const id of toFetchMissing) {
logInfo(
`Fetching project ID ${id} directly... (${++count} / ${toFetchMissing.length})`,
);
try {
// In case something fails to download; catch, rewrite, rethrow.
missingModInfos.push(fetchProject(id));
} catch (err) {
logError(`Couldn't fetch project ID ${id}. See Below.`);
throw err;
}
}
// The code above is expected to throw and terminate the further execution,
// so we can just do this.
modInfos.push(...(await Promise.all(missingModInfos)));
}
}
return modInfos;
}
/**
* Downloads mods from the manifest.
* @param toFetch The files to fetch
* @param destination The dir to put all the mods in. The mods will go into that dir, and not into a sub dir!
*/
export async function fetchMods(
toFetch: ModpackManifestFile[],
destination: string,
): Promise<void> {
if (toFetch.length > 0) {
logInfo(`Fetching ${toFetch.length} mods...`);
let fetched = 0;
await Promise.all(
toFetch.map(async (file): Promise<void> => {
const fileInfo = await fetchFileInfo(file.projectID, file.fileID);
const fileDef: FileDef = {
url: fileInfo.downloadUrl,
};
// https://docs.curseforge.com/#tocS_GetModsByIdsListRequestBody
if (fileInfo.hashes) {
fileDef.hashes = fileInfo.hashes.map((hash) => ({
hashes: hash.value,
id: hash.algo == 1 ? "sha1" : "md5",
}));
}
const modFile = await downloadOrRetrieveFileDef(fileDef);
fetched += 1;
if (modFile.reason == RetrievedFileDefReason.Downloaded) {
logInfo(
`Downloaded ${upath.basename(fileDef.url)}... (${fetched} / ${toFetch.length})`,
);
} else if (modFile.reason == RetrievedFileDefReason.CacheHit) {
logInfo(
`Fetched ${upath.basename(fileDef.url)} from cache... (${fetched} / ${toFetch.length})`,
);
}
const dest = upath.join(destination, fileInfo.fileName);
await fs.promises.symlink(upath.resolve(modFile.cachePath), dest);
}),
);
} else {
logInfo("No mods to fetch.");
}
}