From 07600507f34ede7388090bc59757aefc24c0d163 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 10 Nov 2023 20:16:22 +0100 Subject: [PATCH 1/2] [bugfix-342] Fix unable to download playlist with OneClick from certain sites --- src/main/ipcs/bs-playlist-ipcs.ts | 18 +- .../local-playlists-manager.service.ts | 171 +++++++----------- src/main/services/request.service.ts | 2 +- src/main/services/window-manager.service.ts | 5 +- src/main/util.ts | 5 +- .../services/playlist-downloader.service.ts | 59 ++---- .../OneClick/OneClickDownloadPlaylist.tsx | 92 ++++------ src/shared/models/maps/index.ts | 1 - .../models/playlists/playlist.interface.ts | 12 +- 9 files changed, 138 insertions(+), 227 deletions(-) diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index ff3560d2..0b58dd85 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -2,21 +2,13 @@ import { ipcMain } from "electron"; import { IpcRequest } from "shared/models/ipc"; import { LocalPlaylistsManagerService } from "../services/additional-content/local-playlists-manager.service"; import { UtilsService } from "../services/utils.service"; -import log from "electron-log"; +import { IpcService } from "../services/ipc.service"; -ipcMain.on("one-click-install-playlist", async (event, request: IpcRequest) => { - const utils = UtilsService.getInstance(); - const playlists = LocalPlaylistsManagerService.getInstance(); +const ipc = IpcService.getInstance(); - playlists - .oneClickInstallPlaylist(request.args) - .then(() => { - utils.ipcSend(request.responceChannel, { success: true }); - }) - .catch(e => { - log.error(e); - utils.ipcSend(request.responceChannel, { success: false, error: e }); - }); +ipc.on("one-click-install-playlist", (req, reply) => { + const mapsManager = LocalPlaylistsManagerService.getInstance(); + reply(mapsManager.oneClickInstallPlaylist(req.args)); }); ipcMain.on("register-playlists-deep-link", async (event, request: IpcRequest) => { diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index 4e713c5a..ded5ab54 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -1,19 +1,17 @@ import path from "path"; -import { BehaviorSubject, Observable, lastValueFrom, of } from "rxjs"; +import { Observable, lastValueFrom, tap } 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 "../../../shared/helpers/url.helpers"; import { WindowManagerService } from "../window-manager.service"; -import { BPList, DownloadPlaylistProgression } from "shared/models/playlists/playlist.interface"; -import { copyFileSync, readFileSync } from "fs"; +import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; +import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; -import { copy, realpath } from "fs-extra"; -import { ensureFolderExist, pathExist } from "../../helpers/fs.helpers"; +import { copy, copyFile, pathExists, realpath } from "fs-extra"; +import { Progression, ensureFolderExist, pathExist } from "../../helpers/fs.helpers"; import { IpcService } from "../ipc.service"; export class LocalPlaylistsManagerService { @@ -33,7 +31,6 @@ export class LocalPlaylistsManagerService { private readonly versions: BSLocalVersionService; private readonly maps: LocalMapsManagerService; - private readonly utils: UtilsService; private readonly request: RequestService; private readonly deepLink: DeepLinkService; private readonly windows: WindowManagerService; @@ -43,7 +40,6 @@ export class LocalPlaylistsManagerService { 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(); @@ -54,26 +50,10 @@ export class LocalPlaylistsManagerService { 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"; @@ -88,19 +68,18 @@ export class LocalPlaylistsManagerService { return folder; } - private async installBPListFile(bpListUrlOrPath: string, version: BSVersion): Promise { + private async installBPListFile(bslistSource: string, version: BSVersion): Promise { const playlistFolder = await this.getPlaylistsFolder(version); + const isLocalFile = await pathExists(bslistSource).catch(e => { log.error(e); return false; }); + const filename = isLocalFile ? path.basename(bslistSource) : new URL(bslistSource).pathname.split('/').pop() + const destFile = path.join(playlistFolder, filename); - const bpListDest = path.join(playlistFolder, path.basename(bpListUrlOrPath)); - - if (await pathExist(bpListUrlOrPath)) { - copyFileSync(bpListUrlOrPath, bpListDest); - } else { - await lastValueFrom(this.request.downloadFile(bpListUrlOrPath, bpListDest)); + if (isLocalFile) { + return copyFile(bslistSource, destFile).then(() => destFile); } - - return bpListDest; + return lastValueFrom(this.request.downloadFile(bslistSource, destFile)).then(res => res.data); } + private async readPlaylistFile(path: string): Promise { if (!(await pathExist(path))) { @@ -113,99 +92,87 @@ export class LocalPlaylistsManagerService { } private openOneClickDownloadPlaylistWindow(downloadUrl: string): void { - this.windows.openWindow("oneclick-download-playlist.html").then(window => { - - this.ipc.once("one-click-playlist-info", (_, reply) => { - reply(of({ bpListUrl: downloadUrl, id: this.getPlaylistIdFromDownloadUrl(downloadUrl) })) - }, window.webContents.ipc); - - }); + this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`); } - public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable { - const res = new BehaviorSubject({ progression: 0, current: null, downloadedMaps: [], mapsPath: [], bpListPath: "" }); + public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable> { - 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 }); - } - ); + return new Observable>(obs => { + (async () => { - const observer = async () => { - try { - const bpListPath = await this.installBPListFile(bpListUrl, version); + const bpListFilePath = await this.installBPListFile(bpListUrl, version); + const bpList = await this.readPlaylistFile(bpListFilePath); + + const progress: Progression = { + total: bpList.songs.length, + current: 0, + data: { + downloadedMaps: [], + currentDownload: null, + playlistInfos: bpList, + playlistPath: bpListFilePath, + } + }; - res.next({ ...res.value, bpListPath }); - - const bpList = await this.readPlaylistFile(bpListPath); + obs.next(progress); for (const song of bpList.songs) { - if (!song.key) { + const [ mapDetail ] = await this.bsaver.getMapDetailsFromHashs([song.hash]); + + if(!mapDetail) { continue; } - const map = await this.bsaver.getMapDetailsById(song.key); + const downloadedMap = await this.maps.downloadMap(mapDetail, version); - res.next({ - ...res.value, - current: map, - progression: ((res.value.downloadedMaps.length + 0.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.path], - progression, - }); + progress.data.downloadedMaps.push(downloadedMap); + progress.data.currentDownload = mapDetail; + progress.current += 1; + obs.next(progress); } - } catch (e) { - res.error(e); - } - res.complete(); - }; + })() + .catch(err => obs.error(err)) + .finally(() => obs.complete()); + }); - observer().finally(() => sub.unsubscribe()); - return res.asObservable(); } - public async oneClickInstallPlaylist(bpListUrl: string): Promise { - const versions = await this.versions.getInstalledVersions(); + public oneClickInstallPlaylist(bpListUrl: string): Observable> { - const { bpListPath, mapsPath } = await lastValueFrom(this.downloadPlaylist(bpListUrl, versions.pop())); + return new Observable>(obs => { + (async () => { + const versions = await this.versions.getInstalledVersions(); - if(mapsPath?.length === 0 || !bpListPath) { - return; - } + const download$ = this.downloadPlaylist(bpListUrl, versions.pop()).pipe(tap({ + next: progress => obs.next(progress), + error: err => obs.error(err), + })); - const realSourceMapsFolder = await realpath(path.dirname(mapsPath[0])); + const { data: {downloadedMaps, playlistPath} } = await lastValueFrom(download$); - for (const version of versions) { - await this.installBPListFile(bpListPath, version); + if(downloadedMaps?.length === 0 || !playlistPath) { return; } - const versionMapsFolder = await this.maps.getMapsFolderPath(version); - const realDestMapsFolder = await realpath(versionMapsFolder); + const realSourceMapsFolder = await realpath(path.dirname(downloadedMaps[0].path)); - if(realSourceMapsFolder === realDestMapsFolder) { - continue; - } + for (const version of versions) { + await this.installBPListFile(playlistPath, version); - for (const mapPath of mapsPath) { + const versionMapsFolder = await this.maps.getMapsFolderPath(version); + const realDestMapsFolder = await realpath(versionMapsFolder); - const mapDest = path.join(versionMapsFolder, path.basename(mapPath)); + if(realSourceMapsFolder === realDestMapsFolder) { continue; } - await copy(mapPath, mapDest, { overwrite: true }); - } - } + for (const mapPath of downloadedMaps) { + const mapDest = path.join(versionMapsFolder, path.basename(mapPath.path)); + await copy(mapPath.path, mapDest, { overwrite: true }); + } + } + + })() + .catch(err => obs.error(err)) + .finally(() => obs.complete()); + }); } public enableDeepLinks(): boolean { diff --git a/src/main/services/request.service.ts b/src/main/services/request.service.ts index 0864d6b4..18781331 100644 --- a/src/main/services/request.service.ts +++ b/src/main/services/request.service.ts @@ -59,7 +59,7 @@ export class RequestService { req.on("error", err => { subscriber.error(err); }); - }).pipe(tap({ error: e => log.error(e) }), shareReplay(1)); + }).pipe(tap({ error: e => log.error(e, url, dest) }), shareReplay(1)); } public downloadBuffer(url: string): Observable> { diff --git a/src/main/services/window-manager.service.ts b/src/main/services/window-manager.service.ts index e4cdf33c..c4d2f7e5 100644 --- a/src/main/services/window-manager.service.ts +++ b/src/main/services/window-manager.service.ts @@ -66,9 +66,10 @@ export class WindowManagerService { return promise.then(() => window); } - public openWindow(windowType: AppWindow, options?: BrowserWindowConstructorOptions): Promise { + public openWindow(url: AppWindow, options?: BrowserWindowConstructorOptions): Promise { + const windowType = url.split("?")[0] as AppWindow; const window = new BrowserWindow({ ...(this.appWindowsOptions[windowType] ?? {}), ...this.baseWindowOption, ...options }); - return this.handleNewWindow(windowType, window); + return this.handleNewWindow(url, window); } public closeAllWindows(except?: AppWindow) { diff --git a/src/main/util.ts b/src/main/util.ts index a2fa6647..c9dac068 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -7,9 +7,8 @@ export let resolveHtmlPath: (htmlFileName: string) => string; if (process.env.NODE_ENV === "development") { const port = process.env.PORT || 1212; resolveHtmlPath = (htmlFileName: string) => { - const url = new URL(`http://localhost:${port}`); - url.pathname = htmlFileName; - return url.href; + const url = new URL(`http://localhost:${port}/${htmlFileName}`); + return url.toString(); }; } else { resolveHtmlPath = (htmlFileName: string) => { diff --git a/src/renderer/services/playlist-downloader.service.ts b/src/renderer/services/playlist-downloader.service.ts index 7a594816..94b761a2 100644 --- a/src/renderer/services/playlist-downloader.service.ts +++ b/src/renderer/services/playlist-downloader.service.ts @@ -1,9 +1,8 @@ -import { map } from "rxjs/operators"; -import { Observable, Subject } from "rxjs"; -import { DownloadPlaylistProgression } from "shared/models/playlists/playlist.interface"; +import { Observable, map, tap, throwError } from "rxjs"; +import { DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; import { IpcService } from "./ipc.service"; import { ProgressBarService } from "./progress-bar.service"; -import { ProgressionInterface } from "shared/models/progress-bar"; +import { Progression } from "main/helpers/fs.helpers"; export class PlaylistDownloaderService { private static instance: PlaylistDownloaderService; @@ -15,7 +14,6 @@ export class PlaylistDownloaderService { return PlaylistDownloaderService.instance; } - private readonly progressWatcher$ = new Subject(); private readonly progress: ProgressBarService; private readonly ipc: IpcService; @@ -23,51 +21,22 @@ export class PlaylistDownloaderService { private constructor() { this.progress = ProgressBarService.getInstance(); this.ipc = IpcService.getInstance(); - - this.ipc - .watch("download-playlist-progress") - .pipe(map(val => val.data)) - .subscribe(progress => { - this.progressWatcher$.next(progress); - }); } - private get downloadProgression$(): Observable { - return this.progressWatcher$.pipe( - map(value => { - return { progression: value?.progression ?? 0, label: value?.current?.name ?? "" }; - }) - ); - } + public oneClickInstallPlaylist(bpListUrl: string): Observable> { - public get progress$(): Observable { - return this.progressWatcher$.asObservable(); - } + if(!this.progress.require()){ + return throwError(() => new Error("Download already in progress")); + } - public oneClickInstallPlaylist(bpListUrl: string): Observable { - this.progressWatcher$.next(null); + const download$ = this.ipc.sendV2, string>("one-click-install-playlist", { args: bpListUrl }); + const progress$ = download$.pipe(map(data => (data.current / data.total) * 100)); - const res = new Subject(); + this.progress.show(progress$, true); - this.progress.show(this.downloadProgression$, true); - - const sub = this.progressWatcher$.subscribe(progress => { - res.next(progress); - if (progress.progression === 100) { - this.progress.showFake(0.08); - } - }); - - this.ipc.send("one-click-install-playlist", { args: bpListUrl }).then(oneClickRes => { - sub.unsubscribe(); - this.progress.hide(true); - this.progressWatcher$.next(null); - if (!oneClickRes.success) { - return res.error(oneClickRes.error); - } - res.complete(); - }); - - return res.asObservable(); + return download$.pipe(tap({ + error: () => this.progress.hide(true), + complete: () => this.progress.hide(true) + })); } } diff --git a/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx b/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx index 10519fe4..8dab7d1b 100644 --- a/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx +++ b/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx @@ -1,85 +1,69 @@ import { motion } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { BsmProgressBar } from "renderer/components/progress-bar/bsm-progress-bar.component"; import { BsmImage } from "renderer/components/shared/bsm-image.component"; import TitleBar from "renderer/components/title-bar/title-bar.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { useTranslation } from "renderer/hooks/use-translation.hook"; -import { IpcService } from "renderer/services/ipc.service"; import { NotificationService } from "renderer/services/notification.service"; import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; -import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service"; -import { map, filter } from "rxjs/operators"; -import { BsvPlaylist } from "shared/models/maps/beat-saver.model"; +import { map, filter, take, lastValueFrom } from "rxjs"; import defaultImage from "../../../../assets/images/default-version-img.jpg"; import { useService } from "renderer/hooks/use-service.hook"; -import { lastValueFrom } from "rxjs"; +import { useConstant } from "renderer/hooks/use-constant.hook"; +import { BPList } from "shared/models/playlists/playlist.interface"; +import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; export default function OneClickDownloadPlaylist() { + - const ipc = useService(IpcService); - const bSaver = useService(BeatSaverService); - const playlistDownloader = useService(PlaylistDownloaderService); - const mapsContainer = useRef(null); - const notification = useService(NotificationService); - - const [playlist, setPlaylist] = useState(null); - const downloadedMap = useObservable( - playlistDownloader.progress$.pipe( - filter(download => !!download), - map(download => [...(download.downloadedMaps ?? []), download?.current]) - ), - [] - ); const t = useTranslation(); - - const cover = playlist ? playlist.playlistImage : null; - const title = playlist ? playlist.name : null; + const playlistDownloader = useService(PlaylistDownloaderService); + const notification = useService(NotificationService); + + const mapsContainer = useRef(null); + const playlistUrl = useConstant(() => new URLSearchParams(window.location.search).get("playlistUrl")); + const download$ = useConstant(() => playlistDownloader.oneClickInstallPlaylist(playlistUrl)); + const playlistInfos = useObservable(download$.pipe(filter(progress => !!progress.data?.playlistInfos), map(progress => progress.data.playlistInfos), take(1))); + const downloadedMaps = useObservable(download$.pipe(filter(progress => !!progress.data?.downloadedMaps), map(progress => progress.data.downloadedMaps))); useEffect(() => { - const promise = (async () => { - const infos = await lastValueFrom(ipc.sendV2<{ bpListUrl: string; id: string }>("one-click-playlist-info")); - - bSaver.getPlaylistDetailsById(infos.id).then(details => setPlaylist(details)); - - return lastValueFrom(playlistDownloader.oneClickInstallPlaylist(infos.bpListUrl)); - })(); - - promise.catch(() => { - notification.notifySystem({ title: t("notifications.types.error"), body: t("notifications.playlists.one-click-install.error") }); - }); - - promise.then(() => { + lastValueFrom(download$).then(() => { notification.notifySystem({ title: "OneClick", body: t("notifications.playlists.one-click-install.success") }); - }); - - promise.finally(() => { + }).catch(() => { + notification.notifySystem({ title: t("notifications.types.error"), body: t("notifications.playlists.one-click-install.error") }); + }).finally(() => { window.electron.window.close(); }); - + }, []); - useEffect(() => { - setTimeout( - () => - mapsContainer.current.scrollTo({ - left: mapsContainer.current.scrollWidth, - behavior: "smooth", - }), - 500 - ); - }, [downloadedMap]); + useOnUpdate(() => { + setTimeout(() => { + mapsContainer.current.scrollTo({ left: mapsContainer.current.scrollWidth, behavior: "smooth",}); + }, 500); + }, [downloadedMaps]); + + const playlistImage = () => { + if(!playlistInfos?.image) return defaultImage; + if (playlistInfos?.image.startsWith("data:image")) { + return playlistInfos?.image; + } + return `data:image/png;base64,${playlistInfos?.image}`; + } return (
- {playlist && } + {playlistInfos && }
- -

{title}

+ +

{playlistInfos?.playlistTitle}

-
{downloadedMap?.map(map => map?.versions?.at(0)?.coverURL && )}
+
{downloadedMaps?.map(map => map?.coverUrl && + + )}
diff --git a/src/shared/models/maps/index.ts b/src/shared/models/maps/index.ts index 78a8c35c..fe3e504a 100644 --- a/src/shared/models/maps/index.ts +++ b/src/shared/models/maps/index.ts @@ -1,3 +1,2 @@ export { RawMapInfoData, RawMapDifficulty, RawDifficultySet } from "./raw-map.model"; - export { BsvInstant, BsvMapDetail, BsvMapDetailMetadata, BsvMapDifficulty, BsvMapParitySummary, BsvMapStats, BsvMapTestplay, BsvMapVersion, BsvUserDetail } from "./beat-saver.model"; diff --git a/src/shared/models/playlists/playlist.interface.ts b/src/shared/models/playlists/playlist.interface.ts index 89f3a1c0..c483eb96 100644 --- a/src/shared/models/playlists/playlist.interface.ts +++ b/src/shared/models/playlists/playlist.interface.ts @@ -1,4 +1,5 @@ import { BsvMapDetail } from "../maps"; +import { BsmLocalMap } from "../maps/bsm-local-map.interface"; export interface BPList { playlistTitle: string; @@ -16,10 +17,9 @@ export interface PlaylistSong { uploader?: string; } -export interface DownloadPlaylistProgression { - mapsPath: string[]; - downloadedMaps: BsvMapDetail[]; - bpListPath: string; - current: BsvMapDetail; - progression: number; +export interface DownloadPlaylistProgressionData { + downloadedMaps: BsmLocalMap[]; + currentDownload: BsvMapDetail; + playlistInfos: BPList; + playlistPath: string; } From b520c4463c730f4c0c8468b4612a342c8def85b8 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 10 Nov 2023 20:20:15 +0100 Subject: [PATCH 2/2] [bugfix-342] remove sonarcloud code smell --- src/main/services/window-manager.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/window-manager.service.ts b/src/main/services/window-manager.service.ts index c4d2f7e5..0450eb40 100644 --- a/src/main/services/window-manager.service.ts +++ b/src/main/services/window-manager.service.ts @@ -67,7 +67,7 @@ export class WindowManagerService { } public openWindow(url: AppWindow, options?: BrowserWindowConstructorOptions): Promise { - const windowType = url.split("?")[0] as AppWindow; + const windowType = url.split("?")[0]; const window = new BrowserWindow({ ...(this.appWindowsOptions[windowType] ?? {}), ...this.baseWindowOption, ...options }); return this.handleNewWindow(url, window); }