mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
Merge branch 'master' into feature/Moving-config-file/27
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
import path from "path";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { BsvMapDetail, RawMapInfoData } from "shared/models/maps";
|
||||
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
|
||||
import { BSLocalVersionService } from "../bs-local-version.service";
|
||||
import { InstallationLocationService } from "../installation-location.service";
|
||||
import { UtilsService } from "../utils.service";
|
||||
import crypto from "crypto";
|
||||
import { lstatSync, symlinkSync, unlinkSync, readdirSync, createWriteStream } from "fs";
|
||||
import { copySync } from "fs-extra";
|
||||
import StreamZip from "node-stream-zip";
|
||||
import { RequestService } from "../request.service";
|
||||
import sanitize from "sanitize-filename";
|
||||
import archiver from "archiver";
|
||||
import { DeepLinkService } from "../deep-link.service";
|
||||
import log from 'electron-log';
|
||||
import { WindowManagerService } from "../window-manager.service";
|
||||
import { ipcMain } from "electron";
|
||||
import { IpcRequest } from 'shared/models/ipc';
|
||||
|
||||
export class LocalMapsManagerService {
|
||||
|
||||
private static instance: LocalMapsManagerService;
|
||||
|
||||
public static getInstance(): LocalMapsManagerService{
|
||||
if(!LocalMapsManagerService.instance){ LocalMapsManagerService.instance = new LocalMapsManagerService(); }
|
||||
return LocalMapsManagerService.instance;
|
||||
}
|
||||
|
||||
private readonly LEVELS_ROOT_FOLDER = "Beat Saber_Data";
|
||||
private readonly CUSTOM_LEVELS_FOLDER = "CustomLevels";
|
||||
|
||||
private readonly DEEP_LINKS = {
|
||||
BeatSaver: "beatsaver",
|
||||
ScoreSaber: "web+bsmap"
|
||||
};
|
||||
|
||||
private readonly localVersion: BSLocalVersionService;
|
||||
private readonly installLocation: InstallationLocationService;
|
||||
private readonly utils: UtilsService;
|
||||
private readonly reqService: RequestService;
|
||||
private readonly deepLink: DeepLinkService;
|
||||
private readonly windows: WindowManagerService;
|
||||
|
||||
private constructor(){
|
||||
this.localVersion = BSLocalVersionService.getInstance();
|
||||
this.installLocation = InstallationLocationService.getInstance();
|
||||
this.utils = UtilsService.getInstance();
|
||||
this.reqService = RequestService.getInstance();
|
||||
this.deepLink = DeepLinkService.getInstance();
|
||||
this.windows = WindowManagerService.getInstance();
|
||||
|
||||
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, (link) => {
|
||||
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
|
||||
this.openOneClickDownloadMapWindow(new URL(link).host);
|
||||
});
|
||||
|
||||
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.ScoreSaber, (link) => {
|
||||
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.ScoreSaber, link);
|
||||
this.openOneClickDownloadMapWindow(new URL(link).host, true);
|
||||
});
|
||||
}
|
||||
|
||||
public async getMapsFolderPath(version?: BSVersion): Promise<string>{
|
||||
if(version){ return path.join(await this.localVersion.getVersionPath(version), this.LEVELS_ROOT_FOLDER, this.CUSTOM_LEVELS_FOLDER); }
|
||||
const sharedMapsPath = path.join(this.installLocation.sharedMapsPath, this.CUSTOM_LEVELS_FOLDER);
|
||||
if(!(await this.utils.pathExist(sharedMapsPath))){
|
||||
this.utils.createFolderIfNotExist(sharedMapsPath);
|
||||
}
|
||||
return sharedMapsPath;
|
||||
}
|
||||
|
||||
private async computeMapHash(mapPath: string, rawInfoString: string): Promise<string>{
|
||||
const mapRawInfo = JSON.parse(rawInfoString);
|
||||
let content = rawInfoString;
|
||||
for(const set of mapRawInfo._difficultyBeatmapSets){
|
||||
for(const diff of set._difficultyBeatmaps){
|
||||
const diffFilePath = path.join(mapPath, diff._beatmapFilename);
|
||||
if(!await this.utils.pathExist(diffFilePath)){ continue; }
|
||||
const diffContent = (await this.utils.readFileAsync(diffFilePath)).toString();
|
||||
content += diffContent;
|
||||
}
|
||||
}
|
||||
|
||||
const shasum = crypto.createHash("sha1");
|
||||
shasum.update(content);
|
||||
return shasum.digest("hex");
|
||||
}
|
||||
|
||||
private async loadMapInfoFromPath(mapPath: string): Promise<BsmLocalMap>{
|
||||
const infoFilePath = path.join(mapPath, "Info.dat");
|
||||
|
||||
if(!(await this.utils.pathExist(infoFilePath))){ return null; }
|
||||
|
||||
const rawInfoString = await (await (this.utils.readFileAsync(infoFilePath))).toString();
|
||||
|
||||
const rawInfo: RawMapInfoData = JSON.parse(rawInfoString);
|
||||
const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href;
|
||||
const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href;
|
||||
|
||||
const hash = await this.computeMapHash(mapPath, rawInfoString);
|
||||
|
||||
return {rawInfo, coverUrl, songUrl, hash};
|
||||
}
|
||||
|
||||
private async downloadMapZip(zipUrl: string): Promise<{zip: StreamZip.StreamZipAsync, zipPath: string}>{
|
||||
const fileName = path.basename(zipUrl);
|
||||
const tempPath = this.utils.getTempPath();
|
||||
this.utils.createFolderIfNotExist(this.utils.getTempPath());
|
||||
const dest = path.join(tempPath, fileName);
|
||||
|
||||
const zipPath = await this.reqService.downloadFile(zipUrl, dest);
|
||||
const zip = new StreamZip.async({file : zipPath});
|
||||
|
||||
return {zip, zipPath};
|
||||
}
|
||||
|
||||
private async getAbsoluteFolderOfMaps(maps: BsmLocalMap[], version: BSVersion): Promise<string[]>{
|
||||
|
||||
const mapsFolder = await this.getMapsFolderPath(version);
|
||||
|
||||
const res: string[] = [];
|
||||
const mapHashs = maps.map(map => map.hash);
|
||||
|
||||
const mapsFolders = readdirSync(mapsFolder, {withFileTypes: true});
|
||||
|
||||
for(const content of mapsFolders){
|
||||
if(!content.isDirectory()){ continue; }
|
||||
const mapFolderPath = path.join(mapsFolder, content.name);
|
||||
const { hash } = await this.loadMapInfoFromPath(mapFolderPath);
|
||||
if(mapHashs.includes(hash)){
|
||||
res.push(mapFolderPath);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
|
||||
}
|
||||
|
||||
private openOneClickDownloadMapWindow(mapId: string, isHash = false): void{
|
||||
|
||||
ipcMain.once("one-click-map-info", async (event, req: IpcRequest<void>) => {
|
||||
this.utils.ipcSend(req.responceChannel, {success: true, data: {id: mapId, isHash}});
|
||||
});
|
||||
|
||||
this.windows.openWindow("oneclick-download-map.html");
|
||||
|
||||
}
|
||||
|
||||
public async getMaps(version?: BSVersion): Promise<BsmLocalMap[]>{
|
||||
const levelsFolder = await this.getMapsFolderPath(version);
|
||||
|
||||
const levelsPath = (await this.utils.pathExist(levelsFolder)) ? this.utils.listDirsInDir(levelsFolder, true) : [];
|
||||
|
||||
const mapsInfo = await Promise.all(levelsPath.map(levelPath => this.loadMapInfoFromPath(levelPath)));
|
||||
|
||||
return mapsInfo.filter(info => !!info);
|
||||
}
|
||||
|
||||
public async versionIsLinked(version: BSVersion): Promise<boolean>{
|
||||
|
||||
const levelsPath = await this.getMapsFolderPath(version);
|
||||
|
||||
const isPathExist = await this.utils.pathExist(levelsPath);
|
||||
|
||||
if(!isPathExist){ return false; }
|
||||
|
||||
return lstatSync(levelsPath).isSymbolicLink()
|
||||
}
|
||||
|
||||
public async linkVersionMaps(version: BSVersion, keepMaps: boolean): Promise<void>{
|
||||
|
||||
if(await this.versionIsLinked(version)){ return; }
|
||||
|
||||
const sharedMapsPath = await this.getMapsFolderPath();
|
||||
const versionMapsPath = await this.getMapsFolderPath(version);
|
||||
|
||||
if(keepMaps){
|
||||
await this.utils.moveDirContent(versionMapsPath, sharedMapsPath);
|
||||
}
|
||||
|
||||
await this.utils.deleteFolder(versionMapsPath);
|
||||
|
||||
symlinkSync(sharedMapsPath, versionMapsPath, "junction");
|
||||
}
|
||||
|
||||
public async unlinkVersionMaps(version: BSVersion, keepMaps: boolean): Promise<void>{
|
||||
|
||||
const sharedMapsPath = await this.getMapsFolderPath();
|
||||
const versionMapsPath = await this.getMapsFolderPath(version);
|
||||
|
||||
if(await this.versionIsLinked(version)){
|
||||
unlinkSync(versionMapsPath);
|
||||
}
|
||||
|
||||
this.utils.createFolderIfNotExist(versionMapsPath);
|
||||
|
||||
if(keepMaps){
|
||||
copySync(sharedMapsPath, versionMapsPath);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async deleteMaps(maps: BsmLocalMap[], verion?: BSVersion){
|
||||
|
||||
const mapsFolders = await this.getAbsoluteFolderOfMaps(maps, verion);
|
||||
const mapsHashsToDelete = maps.map(map => map.hash);
|
||||
|
||||
for(const folder of mapsFolders){
|
||||
const { hash } = await this.loadMapInfoFromPath(folder);
|
||||
if(mapsHashsToDelete.includes(hash)){
|
||||
await this.utils.deleteFolder(folder);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise<string>{
|
||||
|
||||
if(!map.versions.at(0).hash){ throw "Cannot download map, no hash found"; }
|
||||
|
||||
const zipUrl = map.versions.at(0).downloadURL;
|
||||
|
||||
const mapsFolder = await this.getMapsFolderPath(version);
|
||||
|
||||
const {zip, zipPath} = await this.downloadMapZip(zipUrl);
|
||||
|
||||
const mapFolderName = sanitize(`${map.id}-${map.name}`);
|
||||
|
||||
const mapPath = path.join(mapsFolder, mapFolderName);
|
||||
|
||||
if(!zip){ throw `Cannot download ${zipUrl}`; }
|
||||
|
||||
this.utils.createFolderIfNotExist(mapPath);
|
||||
|
||||
await zip.extract(null, mapPath);
|
||||
await zip.close();
|
||||
|
||||
unlinkSync(zipPath);
|
||||
|
||||
return mapPath;
|
||||
}
|
||||
|
||||
public async exportMaps(version: BSVersion, maps: BsmLocalMap[], outPath: string){
|
||||
|
||||
const output = createWriteStream(outPath);
|
||||
const archive = archiver("zip", {zlib: {level: 9}});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.on("error", (e) => {throw e});
|
||||
|
||||
if(!maps || maps.length === 0){
|
||||
|
||||
const mapsFolder = await this.getMapsFolderPath(version);
|
||||
archive.directory(mapsFolder, false);
|
||||
|
||||
}
|
||||
else{
|
||||
|
||||
const mapsFolders = await this.getAbsoluteFolderOfMaps(maps, version);
|
||||
|
||||
for(const folder of mapsFolders){
|
||||
archive.directory(folder, path.basename(folder));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
|
||||
}
|
||||
|
||||
public async oneClickDownloadMap(map: BsvMapDetail): Promise<void>{
|
||||
|
||||
const downloadedMap = await this.downloadMap(map);
|
||||
|
||||
const versions = await this.localVersion.getInstalledVersions();
|
||||
|
||||
for(const version of versions){
|
||||
|
||||
if(await this.versionIsLinked(version)){ continue; }
|
||||
|
||||
const versionMapsPath = await this.getMapsFolderPath(version);
|
||||
|
||||
this.utils.createFolderIfNotExist(versionMapsPath);
|
||||
|
||||
copySync(downloadedMap, path.join(versionMapsPath, path.basename(downloadedMap)), {overwrite: true});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enableDeepLinks(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.registerDeepLink(link));
|
||||
}
|
||||
|
||||
public disableDeepLinks(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.unRegisterDeepLink(link));
|
||||
}
|
||||
|
||||
public isDeepLinksEnabled(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { DeepLinkService } from "../deep-link.service";
|
||||
import log from "electron-log"
|
||||
import { ipcMain } from "electron";
|
||||
import { IpcRequest } from "shared/models/ipc";
|
||||
import { UtilsService } from "../utils.service";
|
||||
import { WindowManagerService } from "../window-manager.service";
|
||||
import { MSModel, MSModelType } from "shared/models/model-saber/model-saber.model";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { BSLocalVersionService } from "../bs-local-version.service";
|
||||
import path from "path";
|
||||
import { RequestService } from "../request.service";
|
||||
import { copyFileSync } from "fs-extra";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
export class LocalModelsManagerService {
|
||||
|
||||
private static instance: LocalModelsManagerService;
|
||||
|
||||
public static getInstance(): LocalModelsManagerService{
|
||||
if(!LocalModelsManagerService.instance){ LocalModelsManagerService.instance = new LocalModelsManagerService(); }
|
||||
return LocalModelsManagerService.instance;
|
||||
}
|
||||
|
||||
private readonly DEEP_LINKS = {
|
||||
ModelSaber: "modelsaber",
|
||||
};
|
||||
|
||||
private readonly MODEL_TYPE_FOLDER: Record<Exclude<MSModelType, "misc">, string> = {
|
||||
avatar: "CustomAvatars",
|
||||
bloq: "CustomNotes",
|
||||
platform: "CustomPlatforms",
|
||||
saber: "CustomSabers"
|
||||
}
|
||||
|
||||
private readonly deepLink: DeepLinkService;
|
||||
private readonly utils: UtilsService;
|
||||
private readonly windows: WindowManagerService;
|
||||
private readonly localVersion: BSLocalVersionService;
|
||||
private readonly request: RequestService;
|
||||
|
||||
private constructor(){
|
||||
this.deepLink = DeepLinkService.getInstance();
|
||||
this.utils = UtilsService.getInstance();
|
||||
this.windows = WindowManagerService.getInstance();
|
||||
this.localVersion = BSLocalVersionService.getInstance();
|
||||
this.request = RequestService.getInstance();
|
||||
|
||||
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.ModelSaber, (link) => {
|
||||
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.ModelSaber, link);
|
||||
const url = new URL(link);
|
||||
|
||||
const type = url.host
|
||||
const id = url.pathname.replace("/", '').split("/").at(0);
|
||||
|
||||
this.openOneClickDownloadModelWindow(id, type);
|
||||
});
|
||||
}
|
||||
|
||||
private openOneClickDownloadModelWindow(id: string, type: string){
|
||||
|
||||
ipcMain.once("one-click-model-info", async (event, req: IpcRequest<void>) => {
|
||||
this.utils.ipcSend(req.responceChannel, {success: true, data: {id, type}});
|
||||
});
|
||||
|
||||
this.windows.openWindow("oneclick-download-model.html");
|
||||
|
||||
}
|
||||
|
||||
private async getModelFolderPath(type: MSModelType, version?: BSVersion): Promise<string>{
|
||||
|
||||
if(!version){ throw "will be implemented whith models management" }
|
||||
if(type === "misc"){ throw "model type not supported"; }
|
||||
|
||||
const versionPath = await this.localVersion.getVersionPath(version);
|
||||
const modelFolderPath = path.join(versionPath, this.MODEL_TYPE_FOLDER[type]);
|
||||
|
||||
this.utils.createFolderIfNotExist(modelFolderPath);
|
||||
|
||||
return modelFolderPath;
|
||||
|
||||
}
|
||||
|
||||
public async downloadModel(model: MSModel, version: BSVersion): Promise<string>{
|
||||
|
||||
const modelFolder = await this.getModelFolderPath(model.type, version);
|
||||
const modelDest = path.join(modelFolder, sanitize(path.basename(model.download)));
|
||||
|
||||
return this.request.downloadFile(model.download, modelDest);
|
||||
|
||||
}
|
||||
|
||||
public async oneClickDownloadModel(model: MSModel): Promise<void>{
|
||||
|
||||
if(!model){ return; }
|
||||
|
||||
const versions = await this.localVersion.getInstalledVersions();
|
||||
|
||||
if(versions?.length === 0){ return; }
|
||||
|
||||
const fisrtVersion = versions.shift();
|
||||
|
||||
const downloaded = await this.downloadModel(model, fisrtVersion);
|
||||
|
||||
for(const version of versions){
|
||||
|
||||
const modelDest = path.join(await this.getModelFolderPath(model.type, version), path.basename(downloaded));
|
||||
|
||||
copyFileSync(downloaded, modelDest);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enableDeepLinks(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.registerDeepLink(link));
|
||||
}
|
||||
|
||||
public disableDeepLinks(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.unRegisterDeepLink(link));
|
||||
}
|
||||
|
||||
public isDeepLinksEnabled(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import path from "path";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { BSLocalVersionService } from "../bs-local-version.service";
|
||||
import { DeepLinkService } from "../deep-link.service";
|
||||
import { RequestService } from "../request.service";
|
||||
import { UtilsService } from "../utils.service";
|
||||
import { LocalMapsManagerService } from "./local-maps-manager.service";
|
||||
import log from "electron-log"
|
||||
import { isValidUrl } from "../../helpers/url.helpers";
|
||||
import { ipcMain } from "electron";
|
||||
import { WindowManagerService } from "../window-manager.service";
|
||||
import { IpcRequest } from "shared/models/ipc";
|
||||
import { BPList, DownloadPlaylistProgression } from "shared/models/playlists/playlist.interface";
|
||||
import { copyFileSync, readFileSync } from "fs";
|
||||
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
|
||||
import { copySync } from "fs-extra";
|
||||
|
||||
export class LocalPlaylistsManagerService {
|
||||
|
||||
private static instance: LocalPlaylistsManagerService
|
||||
|
||||
public static getInstance(): LocalPlaylistsManagerService {
|
||||
if (!LocalPlaylistsManagerService.instance) { LocalPlaylistsManagerService.instance = new LocalPlaylistsManagerService(); }
|
||||
return LocalPlaylistsManagerService.instance;
|
||||
}
|
||||
|
||||
private readonly PLAYLISTS_FOLDER = "Playlists";
|
||||
private readonly DEEP_LINKS = {
|
||||
BeatSaver: "bsplaylist",
|
||||
};
|
||||
|
||||
|
||||
private readonly versions: BSLocalVersionService;
|
||||
private readonly maps: LocalMapsManagerService;
|
||||
private readonly utils: UtilsService;
|
||||
private readonly request: RequestService;
|
||||
private readonly deepLink: DeepLinkService;
|
||||
private readonly windows: WindowManagerService;
|
||||
private readonly bsaver: BeatSaverService;
|
||||
|
||||
private constructor(){
|
||||
this.maps = LocalMapsManagerService.getInstance();
|
||||
this.versions = BSLocalVersionService.getInstance();
|
||||
this.utils = UtilsService.getInstance();
|
||||
this.request = RequestService.getInstance();
|
||||
this.deepLink = DeepLinkService.getInstance();
|
||||
this.windows = WindowManagerService.getInstance();
|
||||
this.bsaver = BeatSaverService.getInstance();
|
||||
|
||||
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => {
|
||||
|
||||
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
|
||||
const url = new URL(link);
|
||||
const bplistUrl = url.host === "playlist" ? url.pathname.replace("/", "") : "";
|
||||
|
||||
this.openOneClickDownloadPlaylistWindow(bplistUrl);
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private getPlaylistIdFromDownloadUrl(url: string): string{
|
||||
|
||||
if(!isValidUrl(url)){ return ""; }
|
||||
|
||||
const splited = url.split("/")
|
||||
const idIndex = splited.indexOf("id");
|
||||
|
||||
if(idIndex < 0){ return ""; }
|
||||
|
||||
return splited[idIndex + 1];
|
||||
}
|
||||
|
||||
private async getPlaylistsFolder(version?: BSVersion){
|
||||
|
||||
if(!version){ throw "Playlists are not available to be linked yet" }
|
||||
|
||||
const versionFolder = await this.versions.getVersionPath(version);
|
||||
|
||||
const folder = path.join(versionFolder, this.PLAYLISTS_FOLDER);
|
||||
|
||||
await this.utils.createFolderIfNotExist(folder)
|
||||
|
||||
return folder;
|
||||
|
||||
}
|
||||
|
||||
private async installBPListFile(bpListUrlOrPath: string, version: BSVersion): Promise<string>{
|
||||
|
||||
const playlistFolder = await this.getPlaylistsFolder(version);
|
||||
|
||||
const bpListDest = path.join(playlistFolder, path.basename(bpListUrlOrPath));
|
||||
|
||||
if(this.utils.pathExist(bpListUrlOrPath)){
|
||||
copyFileSync(bpListUrlOrPath, bpListDest);
|
||||
}
|
||||
else{
|
||||
await this.request.downloadFile(bpListUrlOrPath, bpListDest);
|
||||
}
|
||||
|
||||
return bpListDest;
|
||||
|
||||
}
|
||||
|
||||
private async readPlaylistFile(path: string): Promise<BPList>{
|
||||
|
||||
if(!this.utils.pathExist(path)){ throw `bplist file not exist at ${path}`; }
|
||||
|
||||
const rawContent = readFileSync(path).toString();
|
||||
|
||||
return JSON.parse(rawContent);
|
||||
|
||||
}
|
||||
|
||||
private openOneClickDownloadPlaylistWindow(downloadUrl: string): void{
|
||||
|
||||
ipcMain.once("one-click-playlist-info", async (event, req: IpcRequest<void>) => {
|
||||
this.utils.ipcSend(req.responceChannel, {success: true, data: {bpListUrl: downloadUrl, id: this.getPlaylistIdFromDownloadUrl(downloadUrl)}});
|
||||
});
|
||||
|
||||
this.windows.openWindow("oneclick-download-playlist.html");
|
||||
|
||||
}
|
||||
|
||||
public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable<DownloadPlaylistProgression>{
|
||||
|
||||
const res = new BehaviorSubject<DownloadPlaylistProgression>({progression: 0, current: null, downloadedMaps: [], mapsPath: [], bpListPath: ""});
|
||||
|
||||
const sub = res.subscribe(process => {
|
||||
this.utils.ipcSend("download-playlist-progress", {success: true, data: process});
|
||||
}, err => {
|
||||
this.utils.ipcSend("download-playlist-progress", {success: false, error: err});
|
||||
});
|
||||
|
||||
const observer = async () => {
|
||||
|
||||
try{
|
||||
|
||||
const bpListPath = await this.installBPListFile(bpListUrl, version);
|
||||
|
||||
res.next({...res.value, bpListPath});
|
||||
|
||||
const bpList = await this.readPlaylistFile(bpListPath);
|
||||
|
||||
for(const song of bpList.songs){
|
||||
|
||||
if(!song.key){ continue; }
|
||||
|
||||
const map = await this.bsaver.getMapDetailsById(song.key);
|
||||
|
||||
res.next({
|
||||
...res.value,
|
||||
current: map,
|
||||
progression: ((res.value.downloadedMaps.length + .5) / bpList.songs.length) * 100
|
||||
});
|
||||
|
||||
const mapPath = await this.maps.downloadMap(map, version);
|
||||
|
||||
const progression = ((res.value.downloadedMaps.length + 1) / bpList.songs.length) * 100;
|
||||
|
||||
res.next({
|
||||
...res.value,
|
||||
current: null,
|
||||
downloadedMaps: [...res.value.downloadedMaps, map],
|
||||
mapsPath: [...res.value.mapsPath, mapPath],
|
||||
progression
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
catch(e){
|
||||
res.error(e);
|
||||
}
|
||||
|
||||
res.complete();
|
||||
|
||||
}
|
||||
|
||||
observer().finally(() => sub.unsubscribe());
|
||||
return res.asObservable();
|
||||
|
||||
}
|
||||
|
||||
public async oneClickInstallPlaylist(bpListUrl: string): Promise<void>{
|
||||
|
||||
const versions = await this.versions.getInstalledVersions();
|
||||
|
||||
const firstVersion = versions.shift();
|
||||
const fistVersionLinked = await this.maps.versionIsLinked(firstVersion);
|
||||
|
||||
const {bpListPath, mapsPath} = await this.downloadPlaylist(bpListUrl, firstVersion).toPromise();
|
||||
|
||||
for(const version of versions){
|
||||
|
||||
await this.installBPListFile(bpListPath, version);
|
||||
|
||||
const versionIsLinked = await this.maps.versionIsLinked(version);
|
||||
|
||||
if(fistVersionLinked && versionIsLinked){ continue; }
|
||||
|
||||
for(const mapPath of mapsPath){
|
||||
const versionMapsFolder = await this.maps.getMapsFolderPath(version);
|
||||
const mapDest = path.join(versionMapsFolder, path.basename(mapPath));
|
||||
|
||||
copySync(mapPath, mapDest, {overwrite: true});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enableDeepLinks(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.registerDeepLink(link));
|
||||
}
|
||||
|
||||
public disableDeepLinks(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.unRegisterDeepLink(link));
|
||||
}
|
||||
|
||||
public isDeepLinksEnabled(): boolean{
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { BS_APP_ID, BS_DEPOT } from "../constants";
|
||||
import path from "path";
|
||||
import { BSVersion } from 'shared/bs-version.interface';
|
||||
import { UtilsService } from "./utils.service";
|
||||
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
|
||||
import { ChildProcessWithoutNullStreams, spawn, spawnSync } from "child_process";
|
||||
import log from "electron-log";
|
||||
import { InstallationLocationService } from "./installation-location.service";
|
||||
import { ctrlc } from "ctrlc-windows";
|
||||
import { BSLocalVersionService } from "./bs-local-version.service";
|
||||
import isOnline from 'is-online';
|
||||
import { WindowManagerService } from "./window-manager.service";
|
||||
|
||||
export class BSInstallerService{
|
||||
|
||||
@@ -16,6 +17,7 @@ export class BSInstallerService{
|
||||
private readonly utils: UtilsService;
|
||||
private readonly installLocationService: InstallationLocationService;
|
||||
private readonly localVersionService: BSLocalVersionService;
|
||||
private readonly windows: WindowManagerService;
|
||||
|
||||
private downloadProcess: ChildProcessWithoutNullStreams;
|
||||
|
||||
@@ -23,8 +25,9 @@ export class BSInstallerService{
|
||||
this.utils = UtilsService.getInstance();
|
||||
this.installLocationService = InstallationLocationService.getInstance();
|
||||
this.localVersionService = BSLocalVersionService.getInstance();
|
||||
this.windows = WindowManagerService.getInstance();
|
||||
|
||||
this.utils.getMainWindow().on("close", () => {
|
||||
this.windows.getWindows("index.html")?.on("close", () => {
|
||||
this.killDownloadProcess();
|
||||
});
|
||||
}
|
||||
@@ -62,6 +65,18 @@ export class BSInstallerService{
|
||||
});
|
||||
}
|
||||
|
||||
public async isDotNet6Installed(): Promise<boolean>{
|
||||
try{
|
||||
const process = spawnSync(this.getDepotDownloaderExePath());
|
||||
const out = process.output.toString();
|
||||
if(out.includes(".NET runtime can be found at")){ return false; }
|
||||
return true;
|
||||
}
|
||||
catch(e){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async downloadBsVersion(downloadInfos: DownloadInfo): Promise<DownloadEvent>{
|
||||
|
||||
if(this.downloadProcess && this.downloadProcess.connected){ throw "AlreadyDownloading"; }
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { app } from "electron";
|
||||
import path from "path";
|
||||
import { URL } from "url";
|
||||
import log from "electron-log"
|
||||
|
||||
export class DeepLinkService {
|
||||
|
||||
// See docs : https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app#main-process-mainjs
|
||||
|
||||
private static instance: DeepLinkService;
|
||||
|
||||
public static getInstance(): DeepLinkService{
|
||||
if(!DeepLinkService.instance){ DeepLinkService.instance = new DeepLinkService(); }
|
||||
return DeepLinkService.instance;
|
||||
}
|
||||
|
||||
private readonly listeners = new Map<string, Listerner[]>()
|
||||
|
||||
private constructor(){}
|
||||
|
||||
public registerDeepLink(protocol: string): boolean{
|
||||
|
||||
if(process.defaultApp && process.argv.length >= 2){
|
||||
return app.setAsDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
|
||||
}
|
||||
|
||||
return app.setAsDefaultProtocolClient(protocol);
|
||||
}
|
||||
|
||||
public unRegisterDeepLink(protocol: string): boolean{
|
||||
|
||||
if(process.defaultApp && process.argv.length >= 2) {
|
||||
return app.removeAsDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
|
||||
}
|
||||
|
||||
return app.removeAsDefaultProtocolClient(protocol);
|
||||
}
|
||||
|
||||
public isDeepLinkRegistred(protocol: string): boolean{
|
||||
|
||||
if(process.defaultApp && process.argv.length >= 2) {
|
||||
return app.isDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
|
||||
}
|
||||
|
||||
return app.isDefaultProtocolClient(protocol);
|
||||
}
|
||||
|
||||
public addLinkOpenedListener(protocol: string, fn: Listerner){
|
||||
|
||||
if(!this.listeners.has(protocol)){
|
||||
this.listeners.set(protocol, [] as Listerner[]);
|
||||
}
|
||||
|
||||
this.listeners.get(protocol).push(fn);
|
||||
}
|
||||
|
||||
public removeLinkOpenedListener(protocol: string, fn: Listerner){
|
||||
|
||||
if(!this.listeners.get(protocol)?.length){ return; }
|
||||
|
||||
const listeners = this.listeners.get(protocol);
|
||||
const fnIndex = listeners.findIndex(listener => listener === fn);
|
||||
|
||||
if(fnIndex < 0){ return; }
|
||||
|
||||
listeners.splice(fnIndex, 1);
|
||||
|
||||
}
|
||||
|
||||
public dispatchLinkOpened(link: string){
|
||||
|
||||
log.info("DEISPATCH", link);
|
||||
|
||||
const url = new URL(link);
|
||||
|
||||
const protocolListeners = this.listeners.get(url.protocol.replace(":", "")) ?? [];
|
||||
|
||||
protocolListeners.forEach(listerner => {
|
||||
listerner(link);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public isDeepLink(link: string): boolean{
|
||||
|
||||
try{
|
||||
const url = new URL(link);
|
||||
const protocol = url.protocol.replace(":", "");
|
||||
return Array.from(this.listeners.keys()).some(key => key === protocol);
|
||||
}
|
||||
catch(e){
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type Listerner = (link: string) => void;
|
||||
@@ -12,6 +12,11 @@ export class InstallationLocationService {
|
||||
|
||||
private readonly INSTALLATION_FOLDER = "BSManager";
|
||||
private readonly VERSIONS_FOLDER = "BSInstances";
|
||||
|
||||
private readonly SHARED_CONTENT_FOLDER = "SharedContent";
|
||||
private readonly SHARED_MAPS_FOLDER = "SharedMaps";
|
||||
private readonly SHARED_PLAYLISTS_FOLDER = "SharedPlaylists";
|
||||
|
||||
private readonly STORE_INSTALLATION_PATH_KEY = "installation-folder";
|
||||
|
||||
|
||||
@@ -36,9 +41,6 @@ export class InstallationLocationService {
|
||||
this._installationDirectory = this.configService.get<string>(this.STORE_INSTALLATION_PATH_KEY) || app.getPath("documents");
|
||||
}
|
||||
|
||||
public get installationDirectory(): string{ return path.join(this._installationDirectory, this.INSTALLATION_FOLDER); }
|
||||
public get versionsDirectory(): string { return path.join(this.installationDirectory, this.VERSIONS_FOLDER); }
|
||||
|
||||
public setInstallationDirectory(newDir: string): Promise<string>{
|
||||
const oldDir = this.installationDirectory;
|
||||
const newDest = path.join(newDir, this.INSTALLATION_FOLDER);
|
||||
@@ -53,7 +55,15 @@ export class InstallationLocationService {
|
||||
log.error(err);
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
public get installationDirectory(): string{ return path.join(this._installationDirectory, this.INSTALLATION_FOLDER); }
|
||||
|
||||
public get versionsDirectory(): string { return path.join(this.installationDirectory, this.VERSIONS_FOLDER); }
|
||||
|
||||
public get sharedContentPath(): string { return path.join(this.installationDirectory, this.SHARED_CONTENT_FOLDER); }
|
||||
public get sharedMapsPath(): string { return path.join(this.sharedContentPath, this.SHARED_MAPS_FOLDER); }
|
||||
public get sharedPlaylistsPath(): string { return path.join(this.sharedContentPath, this.SHARED_PLAYLISTS_FOLDER); }
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import path from "path";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { BSLocalVersionService } from "./bs-local-version.service";
|
||||
import archiver from "archiver";
|
||||
import { createWriteStream } from "fs";
|
||||
import log from "electron-log";
|
||||
|
||||
export class MapService{
|
||||
|
||||
private readonly MAP_PATH = path.join("Beat Saber_Data", "CustomLevels");
|
||||
|
||||
private static instance: MapService;
|
||||
|
||||
private readonly localVersionService: BSLocalVersionService;
|
||||
|
||||
public static getInstance(): MapService{
|
||||
if(!MapService.instance){ MapService.instance = new MapService(); }
|
||||
return MapService.instance;
|
||||
}
|
||||
|
||||
private constructor(){
|
||||
this.localVersionService = BSLocalVersionService.getInstance();
|
||||
}
|
||||
|
||||
private getMapsPath(versionPath: string): string{
|
||||
return path.join(versionPath, this.MAP_PATH);
|
||||
}
|
||||
|
||||
public async exportVersionMaps(version: BSVersion, outputZip: string): Promise<void>{
|
||||
const versionPath = await this.localVersionService.getVersionPath(version);
|
||||
const mapsPath = this.getMapsPath(versionPath);
|
||||
|
||||
const output = createWriteStream(outputZip);
|
||||
const archive = archiver("zip", {zlib: {level: 9}});
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
archive.pipe(output);
|
||||
archive.directory(mapsPath, false);
|
||||
archive.on("error", reject);
|
||||
output.on("close", resolve);
|
||||
archive.finalize()
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Notification } from "electron";
|
||||
import { SystemNotificationOptions } from "shared/models/notification/system-notification.model";
|
||||
import { UtilsService } from "./utils.service";
|
||||
|
||||
export class NotificationService {
|
||||
|
||||
private static instance: NotificationService;
|
||||
|
||||
public static getInstance(): NotificationService{
|
||||
if(!NotificationService.instance){ NotificationService.instance = new NotificationService(); }
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
private readonly APP_ICON: string;
|
||||
|
||||
private readonly utils: UtilsService;
|
||||
|
||||
private constructor(){
|
||||
this.utils = UtilsService.getInstance();
|
||||
this.APP_ICON = this.utils.getAssetsPath("favicon.ico");
|
||||
}
|
||||
|
||||
public notify(options: SystemNotificationOptions){
|
||||
new Notification({...options, icon: this.APP_ICON}).show();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export class RequestService {
|
||||
|
||||
public downloadFile(url: string, dest: string): Promise<string>{
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
const file = createWriteStream(dest);
|
||||
get(url, res => {
|
||||
res.pipe(file);
|
||||
|
||||
@@ -3,6 +3,7 @@ import regedit from 'regedit'
|
||||
import path from "path";
|
||||
import { parse } from "@node-steam/vdf";
|
||||
import { readFile } from "fs/promises";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export class SteamService{
|
||||
|
||||
@@ -62,4 +63,13 @@ export class SteamService{
|
||||
return null;
|
||||
}
|
||||
|
||||
public openSteam(): Promise<boolean>{
|
||||
const process = spawn("start", ["steam://open/games"], {shell: true});
|
||||
|
||||
return new Promise(resolve => {
|
||||
process.on("exit", () => resolve(true));
|
||||
process.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ApiResult } from "renderer/models/api/api.model";
|
||||
import { BsvMapDetail } from "shared/models/maps";
|
||||
import { BsvPlaylist, BsvPlaylistPage, MapFilter, SearchParams, SearchResponse } from "shared/models/maps/beat-saver.model";
|
||||
import fetch from "node-fetch"
|
||||
|
||||
export class BeatSaverApiService {
|
||||
|
||||
private static instance: BeatSaverApiService;
|
||||
|
||||
public static getInstance(): BeatSaverApiService{
|
||||
if(!BeatSaverApiService.instance){ BeatSaverApiService.instance = new BeatSaverApiService(); }
|
||||
return BeatSaverApiService.instance;
|
||||
}
|
||||
|
||||
private readonly bsaverApiUrl = "https://beatsaver.com/api"
|
||||
|
||||
private constructor(){}
|
||||
|
||||
private mapFilterToUrlParams(filter: MapFilter): URLSearchParams{
|
||||
|
||||
if(!filter){ return new URLSearchParams(); }
|
||||
|
||||
const enbledTagsString = filter.enabledTags ? Array.from(filter.enabledTags) : null;
|
||||
const excludedTagsString = filter.excludedTags ? Array.from(filter.excludedTags).map(tag => `!${tag}`) : null;
|
||||
|
||||
const tags = (enbledTagsString || excludedTagsString) ? [...enbledTagsString, excludedTagsString].join("|") : null;
|
||||
|
||||
const params = {
|
||||
...(filter.automapper && {automapper: String(filter.automapper)}),
|
||||
...(filter.chroma && {chroma: String(filter.chroma)}),
|
||||
...(filter.cinema && {cinema: String(filter.cinema)}),
|
||||
...(filter.me && {me: String(filter.me)}),
|
||||
...(filter.noodle && {noodle: String(filter.noodle)}),
|
||||
...(filter.ranked && {ranked: String(filter.ranked)}),
|
||||
...(filter.verified && {verified: String(filter.verified)}),
|
||||
...(filter.curated && {curated: String(filter.curated)}),
|
||||
...(filter.fullSpread && {fullSpread: String(filter.fullSpread)}),
|
||||
...(filter.from && {from: String(filter.from)}),
|
||||
...(filter.to && {to: String(filter.to)}),
|
||||
...(tags && {tags: String(tags)}),
|
||||
...(filter.minDuration && {minDuration: String(filter.minDuration)}),
|
||||
...(filter.maxDuration && {maxDuration: String(filter.maxDuration)}),
|
||||
...(filter.minNps && {minNps: String(filter.minNps)}),
|
||||
...(filter.maxNps && {maxNps: String(filter.maxNps)})
|
||||
};
|
||||
|
||||
return new URLSearchParams(params);
|
||||
|
||||
}
|
||||
|
||||
private searchParamsToUrlParams(search: SearchParams): URLSearchParams{
|
||||
|
||||
if(!search){ return new URLSearchParams(); }
|
||||
|
||||
const searchParams = {
|
||||
...(search.includeEmpty && {includeEmpty: String(search.includeEmpty)}),
|
||||
...(search.sortOrder && {sortOrder: search.sortOrder}),
|
||||
...(search.q && {q: search.q})
|
||||
};
|
||||
|
||||
const filterUrlParms = this.mapFilterToUrlParams(search.filter);
|
||||
|
||||
return new URLSearchParams({
|
||||
...searchParams,
|
||||
...Object.fromEntries(filterUrlParms)
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public async getMapsDetailsByHashs<T extends string>(hashs: T[]): Promise<ApiResult<Record<Lowercase<T>, BsvMapDetail>>>{
|
||||
|
||||
if(hashs.length > 50){ throw "too musch map hashs"; }
|
||||
|
||||
const paramsHashs = hashs.join(",");
|
||||
const resp = await fetch(`${this.bsaverApiUrl}/maps/hash/${paramsHashs}`);
|
||||
|
||||
const data = await resp.json() as Record<Lowercase<T>, BsvMapDetail> | BsvMapDetail;
|
||||
|
||||
if((data as BsvMapDetail).id){
|
||||
const key = (data as BsvMapDetail).versions.at(0).hash.toLowerCase();
|
||||
const parsedData = {
|
||||
[key]: data as BsvMapDetail
|
||||
} as Record<Lowercase<T>, BsvMapDetail>;
|
||||
|
||||
return {status: resp.status, data: parsedData}
|
||||
}
|
||||
|
||||
return {status: resp.status, data: (data as Record<Lowercase<T>, BsvMapDetail>)};
|
||||
|
||||
}
|
||||
|
||||
public async getMapDetailsById(id: string): Promise<ApiResult<BsvMapDetail>>{
|
||||
|
||||
const res = await fetch(`${this.bsaverApiUrl}/maps/id/${id}`);
|
||||
|
||||
const data = await res.json() as BsvMapDetail;
|
||||
|
||||
return {status: res.status, data};
|
||||
|
||||
}
|
||||
|
||||
public async searchMaps(search: SearchParams): Promise<ApiResult<SearchResponse>>{
|
||||
|
||||
const url = new URL(`${this.bsaverApiUrl}/search/text/${search?.page ?? 0}`);
|
||||
|
||||
url.search = this.searchParamsToUrlParams(search).toString();
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
|
||||
if(!res.ok){
|
||||
return {status: res.status, data: null};
|
||||
}
|
||||
|
||||
const data: any = await res.json();
|
||||
|
||||
return {status: res.status, data};
|
||||
|
||||
}
|
||||
|
||||
public async getPlaylistDetails(id: string): Promise<ApiResult<BsvPlaylist>>{
|
||||
|
||||
const res = await fetch(`${this.bsaverApiUrl}/playlists/id/${id}/0`);
|
||||
|
||||
const data = await res.json() as BsvPlaylistPage;
|
||||
|
||||
return {status: res.status, data: data.playlist};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { splitIntoChunk } from "../../../helpers/array-tools";
|
||||
import { BsvMapDetail } from "shared/models/maps";
|
||||
import { BsvPlaylist, SearchParams } from "shared/models/maps/beat-saver.model";
|
||||
import { BeatSaverApiService } from "./beat-saver-api.service";
|
||||
|
||||
export class BeatSaverService {
|
||||
|
||||
private static instance: BeatSaverService;
|
||||
|
||||
public static getInstance(): BeatSaverService{
|
||||
if(!BeatSaverService.instance){ BeatSaverService.instance = new BeatSaverService(); }
|
||||
return BeatSaverService.instance;
|
||||
}
|
||||
|
||||
private readonly bsaverApi: BeatSaverApiService;
|
||||
|
||||
private readonly cachedMapsDetails = new Map<string, BsvMapDetail>();
|
||||
|
||||
private constructor(){
|
||||
this.bsaverApi = BeatSaverApiService.getInstance();
|
||||
}
|
||||
|
||||
public async getMapDetailsFromHashs(hashs: string[]): Promise<BsvMapDetail[]>{
|
||||
|
||||
const filtredHashs = hashs.map(h => h.toLowerCase()).filter(hash => !Array.from(this.cachedMapsDetails.keys()).includes(hash));
|
||||
const chunkHash = splitIntoChunk(filtredHashs, 50);
|
||||
|
||||
const mapDetails = Array.from(this.cachedMapsDetails.entries()).reduce((res , [hash, details]) => {
|
||||
if(hashs.includes(hash)){
|
||||
res.push(details);
|
||||
}
|
||||
return res;
|
||||
}, [] as BsvMapDetail[]);
|
||||
|
||||
for(const hashs of chunkHash){
|
||||
|
||||
const res = await this.bsaverApi.getMapsDetailsByHashs(hashs);
|
||||
|
||||
if(res.status === 200){
|
||||
mapDetails.push(...Object.values<BsvMapDetail>(res.data).filter(detail => !!detail));
|
||||
mapDetails.forEach(detail => {
|
||||
this.cachedMapsDetails.set(detail.versions.at(0).hash.toLowerCase(), detail);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mapDetails;
|
||||
}
|
||||
|
||||
public async getMapDetailsById(id: string): Promise<BsvMapDetail>{
|
||||
|
||||
const res = await this.bsaverApi.getMapDetailsById(id);
|
||||
return res.data;
|
||||
|
||||
}
|
||||
|
||||
public searchMaps(search: SearchParams): Promise<BsvMapDetail[]>{
|
||||
|
||||
return this.bsaverApi.searchMaps(search).then(res => {
|
||||
return res.status === 200 ? res.data.docs : [];
|
||||
}).catch(err => {
|
||||
return [];
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public async getPlaylistPage(id: string): Promise<BsvPlaylist>{
|
||||
|
||||
const res = await this.bsaverApi.getPlaylistDetails(id);
|
||||
return res.data;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import fetch from "node-fetch";
|
||||
import { ApiResult } from "renderer/models/api/api.model";
|
||||
import { MSGetQuery, MSGetQueryFilter, MSGetResponse } from "shared/models/model-saber/model-saber.model";
|
||||
|
||||
export class ModelSaberApiService {
|
||||
|
||||
private static instance: ModelSaberApiService;
|
||||
|
||||
public static getInstance(): ModelSaberApiService{
|
||||
if(!ModelSaberApiService.instance){ ModelSaberApiService.instance = new ModelSaberApiService(); }
|
||||
return ModelSaberApiService.instance;
|
||||
}
|
||||
|
||||
private readonly API_URL = "https://modelsaber.com/api/v2/";
|
||||
private readonly ENDPOINTS = {get: "get.php", types: "types.php"};
|
||||
|
||||
private constructor(){}
|
||||
|
||||
private parseFilters(filters: MSGetQueryFilter[]): string{
|
||||
|
||||
if(!filters){ return null; }
|
||||
|
||||
const parsed = filters.map(filter => {
|
||||
const stringFilter = filter.type === "searchName" ? filter.value : `${filter.type}:${filter.value}`;
|
||||
return filter.isNegative ? `-${stringFilter}` : stringFilter;
|
||||
});
|
||||
|
||||
return parsed.join(",");
|
||||
|
||||
}
|
||||
|
||||
private buildUrlQuery(query: MSGetQuery): URLSearchParams{
|
||||
|
||||
if(!query){ return new URLSearchParams(); }
|
||||
|
||||
const filterQuery = this.parseFilters(query.filter);
|
||||
|
||||
const searchParams = {
|
||||
...(query.type && {type: query.type}),
|
||||
...(query.platform && {platform: query.platform}),
|
||||
...(query.start && {start: `${query.start}`}),
|
||||
...(query.end && {end: `${query.end}`}),
|
||||
...(query.sort && {sort: query.sort}),
|
||||
...(query.sortDirection && {sortDirection: query.sortDirection}),
|
||||
...(filterQuery && {filter: filterQuery}),
|
||||
}
|
||||
|
||||
return new URLSearchParams(searchParams);
|
||||
|
||||
}
|
||||
|
||||
public async searchModel(query: MSGetQuery): Promise<ApiResult<MSGetResponse>>{
|
||||
|
||||
const url = new URL(this.ENDPOINTS.get, this.API_URL);
|
||||
|
||||
url.search = this.buildUrlQuery(query).toString();
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
|
||||
if(!res.ok){
|
||||
return {data: null, status: res.status};
|
||||
}
|
||||
|
||||
const data = await res.json() as MSGetResponse;
|
||||
|
||||
return {data, status: res.status};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MSGetQuery, MSGetQueryFilter, MSModel } from "shared/models/model-saber/model-saber.model";
|
||||
import { ModelSaberApiService } from "./model-saber-api.service";
|
||||
|
||||
export class ModelSaberService {
|
||||
|
||||
private static instance: ModelSaberService;
|
||||
|
||||
public static getInstance(): ModelSaberService{
|
||||
if(!ModelSaberService.instance){ ModelSaberService.instance = new ModelSaberService(); }
|
||||
return ModelSaberService.instance;
|
||||
}
|
||||
|
||||
private readonly modelSaberApi: ModelSaberApiService;
|
||||
|
||||
private constructor(){
|
||||
this.modelSaberApi = ModelSaberApiService.getInstance();
|
||||
}
|
||||
|
||||
public async getModelById(id: number|string): Promise<MSModel>{
|
||||
|
||||
const query: MSGetQuery = {
|
||||
start: 0,
|
||||
end: 1,
|
||||
platform: "pc",
|
||||
filter: [{type: "id", value: id}]
|
||||
}
|
||||
|
||||
try{
|
||||
const res = await this.modelSaberApi.searchModel(query);
|
||||
|
||||
if(res.status !== 200){ return null; }
|
||||
|
||||
if(Object.keys(res.data).length === 0){
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.data[`${id}`];
|
||||
}
|
||||
catch(e){
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFile, unlinkSync } from "fs";
|
||||
import { existsSync, mkdirSync, readdirSync, readFile, rmSync } from "fs";
|
||||
import { moveSync } from "fs-extra"
|
||||
import { spawnSync } from "child_process";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
@@ -6,6 +7,9 @@ import { app, BrowserWindow } from "electron";
|
||||
import { rm, unlink } from "fs/promises";
|
||||
import { IpcResponse } from "shared/models/ipc";
|
||||
import log from "electron-log";
|
||||
import { AppWindow } from "shared/models/window-manager/app-window.model";
|
||||
|
||||
// TODO : REFACTOR
|
||||
|
||||
export class UtilsService{
|
||||
|
||||
@@ -13,7 +17,7 @@ export class UtilsService{
|
||||
|
||||
private assetsPath: string = '';
|
||||
|
||||
private mainWindow: BrowserWindow;
|
||||
private windows: Map<AppWindow, BrowserWindow> = new Map<AppWindow, BrowserWindow>();
|
||||
|
||||
private constructor(){}
|
||||
|
||||
@@ -29,8 +33,8 @@ export class UtilsService{
|
||||
public getAssestsJsonsPath(): string { return this.getAssetsPath("jsons"); }
|
||||
public getTempPath(): string{ return path.join(app.getPath("temp"), app.getName()) }
|
||||
|
||||
public setMainWindow(win: BrowserWindow){ this.mainWindow = win; }
|
||||
public getMainWindow(){ return this.mainWindow; }
|
||||
public setMainWindows(windows: Map<AppWindow, BrowserWindow>){ this.windows = windows; }
|
||||
public getMainWindows(win: AppWindow){ return this.windows.get(win); }
|
||||
|
||||
public pathExist(path: string): boolean{ return existsSync(path); }
|
||||
|
||||
@@ -43,7 +47,7 @@ export class UtilsService{
|
||||
return unlink(pathToFile);
|
||||
}
|
||||
|
||||
public rmDirIfExist(path: string): Promise<void>{
|
||||
public async rmDirIfExist(path: string): Promise<void>{
|
||||
if(!this.pathExist(path)){ return; }
|
||||
return rm(path, {recursive: true, force: true});
|
||||
}
|
||||
@@ -68,19 +72,33 @@ export class UtilsService{
|
||||
});
|
||||
}
|
||||
|
||||
public listDirsInDir(dirPath: string): string[]{
|
||||
public listDirsInDir(dirPath: string, fullPath = false): string[]{
|
||||
let files = readdirSync(dirPath, { withFileTypes:true});
|
||||
files = files.filter(f => f.isDirectory())
|
||||
return files.map(f => f.name);
|
||||
return files.map(f => fullPath ? path.join(dirPath, f.name) : f.name);
|
||||
}
|
||||
|
||||
public deleteFolder(folderPath: string): Promise<void>{
|
||||
return rm(folderPath, {recursive: true});
|
||||
public async deleteFolder(folderPath: string): Promise<void>{
|
||||
const folderExist = this.pathExist(folderPath);
|
||||
if(!folderExist){ return; }
|
||||
return rmSync(folderPath, {recursive: true});
|
||||
}
|
||||
|
||||
public async moveDirContent(src: string, dest: string, overwrite = false): Promise<void>{
|
||||
const [srcExist, destExist] = await Promise.all([this.pathExist(src), this.pathExist(dest)]);
|
||||
if(!srcExist){ return; }
|
||||
if(!destExist){ await this.createFolderIfNotExist(dest); }
|
||||
readdirSync(src, {encoding: "utf-8"}).forEach(file => {
|
||||
const srcFullPath = path.join(src, file);
|
||||
const destFullPath = path.join(dest, file);
|
||||
if(!overwrite && this.pathExist(destFullPath)){ return; }
|
||||
moveSync(srcFullPath, destFullPath, {overwrite});
|
||||
});
|
||||
}
|
||||
|
||||
public ipcSend<T = any>(channel: string, response: IpcResponse<T>): void{
|
||||
try {
|
||||
this.mainWindow.webContents.send(channel, response);
|
||||
Array.from(this.windows.values()).forEach(window => window.webContents.send(channel, response));
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,32 @@ import { app, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
|
||||
import { resolveHtmlPath } from "../util";
|
||||
import { UtilsService } from "./utils.service";
|
||||
import { AppWindow } from "shared/models/window-manager/app-window.model";
|
||||
import { PRELOAD_PATH } from "../main";
|
||||
import path from "path";
|
||||
import { APP_NAME } from "../constants";
|
||||
|
||||
export class WindowManagerService{
|
||||
|
||||
private static instance: WindowManagerService;
|
||||
|
||||
private readonly PRELOAD_PATH = app.isPackaged ? path.join(__dirname, 'preload.js') : path.join(__dirname, '../../../.erb/dll/preload.js')
|
||||
|
||||
private readonly utilsService: UtilsService = UtilsService.getInstance();
|
||||
|
||||
private readonly appWindowsOptions: Record<AppWindow, BrowserWindowConstructorOptions> = {
|
||||
"launcher.html": {width: 380, height: 500, minWidth: 380, minHeight: 500, resizable: false},
|
||||
"index.html": {width: 1080, height: 720, minWidth: 900, minHeight: 500}
|
||||
"index.html": {width: 1080, height: 720, minWidth: 900, minHeight: 500},
|
||||
"oneclick-download-map.html": {width: 350, height: 400, minWidth: 350, minHeight: 400, resizable: false},
|
||||
"oneclick-download-playlist.html": {width: 350, height: 400, minWidth: 350, minHeight: 400, resizable: false},
|
||||
"oneclick-download-model.html": {width: 350, height: 400, minWidth: 350, minHeight: 400, resizable: false},
|
||||
}
|
||||
|
||||
private readonly baseWindowOption: BrowserWindowConstructorOptions = {
|
||||
title: APP_NAME,
|
||||
icon: this.utilsService.getAssetsPath("favicon.ico"),
|
||||
show: false,
|
||||
frame: false,
|
||||
titleBarOverlay: false,
|
||||
webPreferences: { preload: PRELOAD_PATH }
|
||||
webPreferences: { preload: this.PRELOAD_PATH, webSecurity: false }
|
||||
}
|
||||
|
||||
private readonly windows: Map<AppWindow, BrowserWindow> = new Map<AppWindow, BrowserWindow>();
|
||||
@@ -32,15 +39,16 @@ export class WindowManagerService{
|
||||
|
||||
private constructor(){}
|
||||
|
||||
public openWindow(windowType: AppWindow): Promise<BrowserWindow>{
|
||||
const window = new BrowserWindow({...this.appWindowsOptions[windowType], ...this.baseWindowOption});
|
||||
public openWindow(windowType: AppWindow, options?: BrowserWindowConstructorOptions): Promise<BrowserWindow>{
|
||||
const window = new BrowserWindow({...this.appWindowsOptions[windowType], ...this.baseWindowOption, ...options});
|
||||
|
||||
const promise = window.loadURL(resolveHtmlPath(windowType));
|
||||
window.removeMenu();
|
||||
window.setMenu(null);
|
||||
|
||||
window.once("ready-to-show", () => {
|
||||
if (!window) { throw new Error('"window" is not defined'); }
|
||||
return window.show();
|
||||
window.show();
|
||||
});
|
||||
|
||||
window.once("closed", () => {
|
||||
@@ -49,15 +57,11 @@ export class WindowManagerService{
|
||||
});
|
||||
|
||||
this.windows.set(windowType, window);
|
||||
this.utilsService.setMainWindow(window);
|
||||
this.utilsService.setMainWindows(this.windows);
|
||||
|
||||
return promise.then(() => window);
|
||||
}
|
||||
|
||||
public closeWindow(window: AppWindow){
|
||||
this.windows.get(window).close();
|
||||
}
|
||||
|
||||
public closeAllWindows(except?: AppWindow){
|
||||
this.windows.forEach((window, key) => {
|
||||
if(key === except){ return; }
|
||||
@@ -65,4 +69,14 @@ export class WindowManagerService{
|
||||
})
|
||||
}
|
||||
|
||||
public close(...win: AppWindow[]){
|
||||
win.forEach(window => {
|
||||
this.windows.get(window)?.close();
|
||||
});
|
||||
}
|
||||
|
||||
public getWindows(window: AppWindow): BrowserWindow{
|
||||
return this.windows.get(window);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user