mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[chore] migrate to the new mods repo API
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user