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 { 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 { 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 { 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 = 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[] = []; 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 { 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[] = []; 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 { if (toFetch.length > 0) { logInfo(`Fetching ${toFetch.length} mods...`); let fetched = 0; await Promise.all( toFetch.map(async (file): Promise => { 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."); } }