[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>
347 lines
9.1 KiB
TypeScript
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.");
|
|
}
|
|
}
|