[chore] migrate to the new mods repo API

This commit is contained in:
MathieuG-P
2024-12-31 02:29:27 +01:00
parent 054f457055
commit 9ecb3daf55
10 changed files with 178 additions and 177 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ import { BsModsManagerService } from "../services/mods/bs-mods-manager.service";
import { UtilsService } from "../services/utils.service";
import { BSVersion } from "shared/bs-version.interface";
import { IpcRequest } from "shared/models/ipc";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmFullMod } from "shared/models/mods/mod.interface";
import { InstallModsResult } from "shared/models/mods";
import log from "electron-log";
import { IpcService } from "../services/ipc.service";
@@ -21,7 +21,7 @@ ipc.on<BSVersion>("get-installed-mods", (req, reply) => {
reply(from(modsManager.getInstalledMods(req.args)));
});
ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: Mod[]; version: BSVersion }>) => {
ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: BbmFullMod[]; version: BSVersion }>) => {
const utils = UtilsService.getInstance();
const modsManager = BsModsManagerService.getInstance();
@@ -36,7 +36,7 @@ ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: Mod[]; version: B
});
});
ipcMain.on("uninstall-mods", (event, request: IpcRequest<{ mods: Mod[]; version: BSVersion }>) => {
ipcMain.on("uninstall-mods", (event, request: IpcRequest<{ mods: BbmFullMod[]; version: BSVersion }>) => {
const utils = UtilsService.getInstance();
const modsManager = BsModsManagerService.getInstance();
+21 -40
View File
@@ -1,19 +1,18 @@
import { BSVersion } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmFullMod, BbmMod, BbmModVersion, BbmPlatform } from "../../../shared/models/mods/mod.interface";
import { RequestService } from "../request.service";
import { BsStore } from "../../../shared/models/bs-store.enum";
export class BeatModsApiService {
private static instance: BeatModsApiService;
private readonly requestService: RequestService;
public readonly MODS_REPO_URL = "https://bbm.saera.gay";
private readonly BEAT_MODS_API_URL = `${this.MODS_REPO_URL}/api`;
public readonly MODS_REPO_URL = "https://beatmods.com";
private readonly MODS_REPO_API_URL = `${this.MODS_REPO_URL}/api`;
private readonly versionModsCache = new Map<string, Mod[]>();
private readonly modsHashCache = new Map<string, Mod>();
private allModsCache: Mod[];
private readonly versionModsCache = new Map<string, BbmFullMod[]>();
private readonly modsHashCache = new Map<string, BbmModVersion>();
public static getInstance(): BeatModsApiService {
if (!BeatModsApiService.instance) {
@@ -26,65 +25,47 @@ export class BeatModsApiService {
this.requestService = RequestService.getInstance();
}
private convertToBeatModsMod(mod: Mod): Mod {
}
private getVersionModsUrl(version: BSVersion): string {
return `${this.BEAT_MODS_API_URL}/mods?status=verified&gameVersion=${version.BSVersion}`;
const platform: BbmPlatform = version.oculus || version.metadata.store === BsStore.OCULUS ? BbmPlatform.OculusPC : BbmPlatform.SteamPC;
return `${this.MODS_REPO_API_URL}/mods?status=verified&gameVersion=${version.BSVersion}&gameName=BeatSaber&platform=${platform}`;
}
private asignDependencies(mod: Mod, mods: Mod[]): Mod {
mod.dependencies = mod.dependencies.map(dep => mods.find(mod => mod.name === dep.name));
return mod;
}
private updateModsHashCache(mods: Mod[]): void {
private updateModsHashCache(mods: BbmModVersion[]): void {
if(!Array.isArray(mods)){
return;
}
for (const mod of mods) {
for (const downloads of (mod.downloads ?? [])) {
for (const hashMd5 of (downloads.hashMd5 ?? [])) {
this.modsHashCache.set(hashMd5.hash, mod);
}
}
for (const dep of (mod.dependencies ?? [])) {
for (const downloads of (dep.downloads ?? [])) {
for (const hashMd5 of (downloads.hashMd5 ?? [])) {
this.modsHashCache.set(hashMd5.hash, dep);
}
}
for (const content of (mod.contentHashes ?? [])) {
this.modsHashCache.set(content.hash, mod);
}
}
}
public async getVersionMods(version: BSVersion): Promise<Mod[]> {
public async getVersionMods(version: BSVersion): Promise<BbmFullMod[]> {
if (this.versionModsCache.has(version.BSVersion)) {
return this.versionModsCache.get(version.BSVersion);
}
return this.requestService.getJSON<Mod[]>(this.getVersionModsUrl(version)).then(mods => {
mods = mods.map(mod => this.asignDependencies(mod, mods));
this.versionModsCache.set(version.BSVersion, mods);
return this.requestService.getJSON<{ mods: {mod: BbmMod, latest: BbmModVersion}[] }>(this.getVersionModsUrl(version)).then(res => {
const fullMods: BbmFullMod[] = res?.mods?.map(mod => ({ mod: mod.mod, version: mod.latest })) ?? [];
this.versionModsCache.set(version.BSVersion, fullMods);
this.updateModsHashCache(mods);
this.updateModsHashCache(fullMods.map(mod => mod.version));
return mods;
return fullMods;
});
}
public getModByHash(hash: string): Promise<Mod> {
public getModByHash(hash: string): Promise<BbmModVersion> {
if (this.modsHashCache.has(hash)) {
return Promise.resolve(this.modsHashCache.get(hash));
}
return this.requestService.getJSON<Mod[]>(`${this.BEAT_MODS_API_URL}/hashlookup?hash=${hash}`).then(mods => {
this.updateModsHashCache(mods);
return mods.at(0);
return this.requestService.getJSON<{ modVersions: BbmModVersion[] }>(`${this.MODS_REPO_API_URL}/hashlookup?hash=${hash}`).then(res => {
this.updateModsHashCache(res?.modVersions ?? []);
return res?.modVersions?.at(0);
});
}
}
@@ -1,5 +1,5 @@
import { BSVersion } from "shared/bs-version.interface";
import { DownloadLink, InstallModsResult, Mod, ModInstallProgression, UninstallModsResult } from "shared/models/mods";
import { InstallModsResult, ModInstallProgression, UninstallModsResult } from "shared/models/mods";
import { BeatModsApiService } from "./beat-mods-api.service";
import { BSLocalVersionService } from "../bs-local-version.service";
import path from "path";
@@ -16,6 +16,7 @@ import { sToMs } from "../../../shared/helpers/time.helpers";
import { pathExistsSync } from "fs-extra";
import { BsmZipExtractor } from "../../models/bsm-zip-extractor.class";
import crypto from "crypto";
import { BbmCategories, BbmFullMod, BbmModVersion } from "../../../shared/models/mods/mod.interface";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -25,7 +26,7 @@ export class BsModsManagerService {
private readonly utilsService: UtilsService;
private readonly requestService: RequestService;
private manifestMatches: Mod[];
private manifestMatches: BbmModVersion[];
private nbModsToInstall = 0;
private nbInstalledMods = 0;
@@ -48,18 +49,18 @@ export class BsModsManagerService {
this.utilsService = UtilsService.getInstance();
}
private async getModFromHash(hash: string): Promise<Mod> {
private async getModFromHash(hash: string): Promise<BbmModVersion> {
const mod = await this.beatModsApi.getModByHash(hash);
if(mod?.name?.toLowerCase() === "bsipa"){
if(mod?.contentHashes?.some(content => content.path.includes("IPA.exe"))){
return undefined;
}
return mod;
}
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<Mod[]> {
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<BbmModVersion[]> {
const bsPath = await this.bsLocalService.getVersionPath(version);
const modsPath = path.join(bsPath, modsDir);
@@ -88,7 +89,7 @@ export class BsModsManagerService {
}
if (filePath.toLowerCase().includes("libs")) {
const manifestIndex = this.manifestMatches.findIndex(m => m.name === mod.name);
const manifestIndex = this.manifestMatches.findIndex(m => m.id === mod.id);
if (manifestIndex < 0) {
return undefined;
@@ -104,7 +105,7 @@ export class BsModsManagerService {
return mods.filter(Boolean);
}
private async getBsipaInstalled(version: BSVersion): Promise<Mod> {
private async getBsipaInstalled(version: BSVersion): Promise<BbmModVersion> {
const bsPath = await this.bsLocalService.getVersionPath(version);
const injectorPath = path.join(bsPath, "Beat Saber_Data", "Managed", "IPA.Injector.dll");
if (!(await pathExist(injectorPath))) {
@@ -177,26 +178,23 @@ export class BsModsManagerService {
});
}
private getModDownload(mod: Mod, version: BSVersion): DownloadLink {
return mod.downloads.find(download => {
const type = download.type.toLowerCase();
return type === "universal" || type === this.bsLocalService.getVersionType(version);
});
private getModDownload(modVersion: BbmModVersion): string {
return `/cdn/mod/${modVersion.zipHash}.zip`
}
private async installMod(mod: Mod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.name, "for version", `${version.BSVersion} - ${version.name}`);
this.utilsService.ipcSend<ModInstallProgression>("mod-installed", { success: true, data: { name: mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100 } });
private async installMod(mod: BbmFullMod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.mod.name, "for version", `${version.BSVersion} - ${version.name}`);
this.utilsService.ipcSend<ModInstallProgression>("mod-installed", { success: true, data: { name: mod.mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100 } });
const download = this.getModDownload(mod, version);
const downloadUrl = this.getModDownload(mod.version);
if (!download) {
if (!downloadUrl) {
return false;
}
log.info("Start download mod zip", mod.name, download.url);
const zip = await this.downloadZip(download.url);
log.info("Mod zip download end", mod.name, download.url);
log.info("Start download mod zip", mod.mod.name, downloadUrl);
const zip = await this.downloadZip(downloadUrl);
log.info("Mod zip download end", mod.mod.name, downloadUrl);
if (!zip) {
return false;
@@ -208,18 +206,18 @@ export class BsModsManagerService {
const md5Hash = crypto.createHash("md5")
.update(buffer)
.digest("hex");
hashCount += +download.hashMd5.some(md5 => md5.hash === md5Hash);
hashCount += +mod.version.contentHashes.some(content => content.hash === md5Hash);
}
if (hashCount !== download.hashMd5.length) {
if (hashCount !== mod.version.contentHashes.length) {
return false;
}
const versionPath = await this.bsLocalService.getVersionPath(version);
const isBSIPA = mod.name.toLowerCase() === "bsipa";
const isBSIPA = mod.mod.name.toLowerCase() === "bsipa";
const destDir = isBSIPA ? versionPath : path.join(versionPath, ModsInstallFolder.PENDING);
log.info("Start extracting mod zip", mod.name, "to", destDir);
log.info("Start extracting mod zip", mod.mod.name, "to", destDir);
const extracted = await zip.extract(destDir)
.then(() => true)
.catch(e => {
@@ -230,7 +228,7 @@ export class BsModsManagerService {
zip.close();
});
log.info("Mod zip extraction end", mod.name, "to", destDir, "success:", extracted);
log.info("Mod zip extraction end", mod.mod.name, "to", destDir, "success:", extracted);
const res = isBSIPA
? extracted &&
@@ -245,23 +243,20 @@ export class BsModsManagerService {
return res;
}
private isDependency(mod: Mod, selectedMods: Mod[], availableMods: Mod[]) {
return selectedMods.some(m => {
const deps = m.dependencies.map(dep => Array.from(availableMods.values()).find(m => dep.name === m.name));
if (deps.some(depMod => depMod.name === mod.name)) {
return true;
}
return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name));
});
private isDependency(mod: BbmFullMod, selectedMods: BbmFullMod[], availableMods: BbmFullMod[]): boolean {
const selectedDepsIds = selectedMods.flatMap(mod => mod.version.dependencies);
const modsDeps = availableMods.filter(m => selectedDepsIds.includes(m.version.id));
const modsDepsDepsIds = modsDeps.flatMap(m => m.version.dependencies);
return selectedDepsIds.includes(mod.version.id) || modsDepsDepsIds.includes(mod.version.id);
}
private async resolveDependencies(mods: Mod[], version: BSVersion): Promise<Mod[]> {
private async resolveDependencies(mods: BbmFullMod[], version: BSVersion): Promise<BbmFullMod[]> {
const availableMods = await this.beatModsApi.getVersionMods(version);
return Array.from(
new Map<string, Mod>(
new Map<number, BbmFullMod>(
availableMods.reduce((res, mod) => {
if (mod.required || this.isDependency(mod, mods, availableMods)) {
res.push([mod.name, mod]);
if (mod.mod.category === BbmCategories.Core || this.isDependency(mod, mods, availableMods)) {
res.push([mod.mod.id, mod]);
}
return res;
}, [])
@@ -269,9 +264,7 @@ export class BsModsManagerService {
);
}
private async uninstallBSIPA(mod: Mod, version: BSVersion): Promise<void> {
const download = this.getModDownload(mod, version);
private async uninstallBSIPA(mod: BbmFullMod, version: BSVersion): Promise<void> {
const verionPath = await this.bsLocalService.getVersionPath(version);
const hasIPAExe = await pathExist(path.join(verionPath, "IPA.exe"));
const hasIPADir = await pathExist(path.join(verionPath, "IPA"));
@@ -282,37 +275,38 @@ export class BsModsManagerService {
await this.executeBSIPA(version, ["--revert", "-n"]);
const promises = download.hashMd5.map(files => {
const file = files.file.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
const promises = mod.version.contentHashes.map(content => {
const file = content.path.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
return unlinkPath(path.join(verionPath, file));
});
await Promise.all(promises);
}
private async uninstallMod(mod: Mod, version: BSVersion): Promise<void> {
private async uninstallMod(mod: BbmFullMod, version: BSVersion): Promise<void> {
this.nbUninstalledMods++;
this.utilsService.ipcSend<ModInstallProgression>("mod-uninstalled", { success: true, data: { name: mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100 } });
this.utilsService.ipcSend<ModInstallProgression>("mod-uninstalled", { success: true, data: { name: mod.mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100 } });
if (mod.name.toLowerCase() === "bsipa") {
if (mod.mod.name.toLowerCase() === "bsipa") {
return this.uninstallBSIPA(mod, version);
}
const download = this.getModDownload(mod, version);
const versionPath = await this.bsLocalService.getVersionPath(version);
const promises = download.hashMd5.map(async files => {
return Promise.all([unlinkPath(path.join(versionPath, files.file)), unlinkPath(path.join(versionPath, "IPA", "Pending", files.file))]);
const promises = mod.version.contentHashes.map(async content => {
return Promise.all([unlinkPath(path.join(versionPath, content.path)), unlinkPath(path.join(versionPath, "IPA", "Pending", content.path))]);
});
await Promise.all(promises);
}
public getAvailableMods(version: BSVersion): Promise<Mod[]> {
return this.beatModsApi.getVersionMods(version);
public getAvailableMods(version: BSVersion): Promise<BbmFullMod[]> {
return this.beatModsApi.getVersionMods(version).catch(() => {
return [] as BbmFullMod[];
});
}
public async getInstalledMods(version: BSVersion): Promise<Mod[]> {
public async getInstalledMods(version: BSVersion): Promise<BbmModVersion[]> {
this.manifestMatches = [];
const bsipa = await this.getBsipaInstalled(version);
@@ -322,23 +316,23 @@ export class BsModsManagerService {
const dirMods = pluginsMods.flat().concat(libsMods.flat());
const modsDict = new Map<string, Mod>();
const modsDict = new Map<number, BbmModVersion>();
if (bsipa) {
modsDict.set(bsipa.name, bsipa);
modsDict.set(bsipa.id, bsipa);
}
for (const mod of dirMods.flat()) {
if (modsDict.has(mod.name)) {
if (modsDict.has(mod.id)) {
continue;
}
modsDict.set(mod.name, mod);
modsDict.set(mod.id, mod);
}
return Array.from(modsDict.values());
}
public async installMods(mods: Mod[], version: BSVersion): Promise<InstallModsResult> {
public async installMods(mods: BbmFullMod[], version: BSVersion): Promise<InstallModsResult> {
const deps = await this.resolveDependencies(mods, version);
mods.push(...deps);
@@ -346,9 +340,9 @@ export class BsModsManagerService {
throw "no-mods";
}
const bsipa = mods.find(mod => mod.name.toLowerCase() === "bsipa");
const bsipa = mods.find(mod => mod.mod.name.toLowerCase() === "bsipa");
if (bsipa) {
mods = mods.filter(mod => mod.name.toLowerCase() !== "bsipa");
mods = mods.filter(mod => mod.mod.name.toLowerCase() !== "bsipa");
}
this.nbModsToInstall = mods.length + (bsipa && 1);
@@ -374,7 +368,7 @@ export class BsModsManagerService {
};
}
public async uninstallMods(mods: Mod[], version: BSVersion): Promise<UninstallModsResult> {
public async uninstallMods(mods: BbmFullMod[], version: BSVersion): Promise<UninstallModsResult> {
if (!mods?.length) {
throw "no-mods";
}
@@ -393,16 +387,21 @@ export class BsModsManagerService {
}
public async uninstallAllMods(version: BSVersion): Promise<UninstallModsResult> {
const mods = await this.getInstalledMods(version);
const versionMods = await this.getAvailableMods(version);
const installedMods = await this.getInstalledMods(version);
if (!mods?.length) {
const fullInstalledMods: BbmFullMod[] = installedMods?.map(version => {
return { version, mod: versionMods.find(mod => version.modId === mod.mod.id)?.mod };
}) ?? [];
if (!fullInstalledMods?.length) {
throw "no-mods";
}
this.nbModsToUninstall = mods.length;
this.nbModsToUninstall = fullInstalledMods.length;
this.nbUninstalledMods = 0;
for (const mod of mods) {
for (const mod of fullInstalledMods) {
await this.uninstallMod(mod, version);
}
+1 -1
View File
@@ -49,7 +49,7 @@ export class RequestService {
const response = await fetch(url, this.getInitWithOptions(options));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status} ${url}`);
throw new Error(`HTTP error! status: ${response.status} response: ${await response.text().catch(() => "No response")} url: ${url}`);
}
return await response.json();