Merge pull request #349 from Zagrios/bugfix/cannot-download-playlist-with-one-click/342

[bugfix-342] Fix unable to download playlist with OneClick from certain websites
This commit is contained in:
MathieuG-P
2023-11-10 20:24:13 +01:00
committed by GitHub
9 changed files with 138 additions and 227 deletions
+5 -13
View File
@@ -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<string>) => {
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<string>("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<void>) => {
@@ -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<string> {
private async installBPListFile(bslistSource: string, version: BSVersion): Promise<string> {
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<BPList> {
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<DownloadPlaylistProgression> {
const res = new BehaviorSubject<DownloadPlaylistProgression>({ progression: 0, current: null, downloadedMaps: [], mapsPath: [], bpListPath: "" });
public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable<Progression<DownloadPlaylistProgressionData>> {
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<Progression<DownloadPlaylistProgressionData>>(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<DownloadPlaylistProgressionData> = {
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<void> {
const versions = await this.versions.getInstalledVersions();
public oneClickInstallPlaylist(bpListUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
const { bpListPath, mapsPath } = await lastValueFrom(this.downloadPlaylist(bpListUrl, versions.pop()));
return new Observable<Progression<DownloadPlaylistProgressionData>>(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 {
+1 -1
View File
@@ -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<Progression<Buffer>> {
+3 -2
View File
@@ -66,9 +66,10 @@ export class WindowManagerService {
return promise.then(() => window);
}
public openWindow(windowType: AppWindow, options?: BrowserWindowConstructorOptions): Promise<BrowserWindow> {
public openWindow(url: AppWindow, options?: BrowserWindowConstructorOptions): Promise<BrowserWindow> {
const windowType = url.split("?")[0];
const window = new BrowserWindow({ ...(this.appWindowsOptions[windowType] ?? {}), ...this.baseWindowOption, ...options });
return this.handleNewWindow(windowType, window);
return this.handleNewWindow(url, window);
}
public closeAllWindows(except?: AppWindow) {
+2 -3
View File
@@ -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) => {
@@ -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<DownloadPlaylistProgression>();
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<DownloadPlaylistProgression>("download-playlist-progress")
.pipe(map(val => val.data))
.subscribe(progress => {
this.progressWatcher$.next(progress);
});
}
private get downloadProgression$(): Observable<ProgressionInterface> {
return this.progressWatcher$.pipe(
map(value => {
return { progression: value?.progression ?? 0, label: value?.current?.name ?? "" };
})
);
}
public oneClickInstallPlaylist(bpListUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
public get progress$(): Observable<DownloadPlaylistProgression> {
return this.progressWatcher$.asObservable();
}
if(!this.progress.require()){
return throwError(() => new Error("Download already in progress"));
}
public oneClickInstallPlaylist(bpListUrl: string): Observable<DownloadPlaylistProgression> {
this.progressWatcher$.next(null);
const download$ = this.ipc.sendV2<Progression<DownloadPlaylistProgressionData>, string>("one-click-install-playlist", { args: bpListUrl });
const progress$ = download$.pipe(map(data => (data.current / data.total) * 100));
const res = new Subject<DownloadPlaylistProgression>();
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<void, string>("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)
}));
}
}
@@ -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<HTMLDivElement>(null);
const notification = useService(NotificationService);
const [playlist, setPlaylist] = useState<BsvPlaylist>(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<HTMLDivElement>(null);
const playlistUrl = useConstant(() => new URLSearchParams(window.location.search).get("playlistUrl"));
const download$ = useConstant(() => playlistDownloader.oneClickInstallPlaylist(playlistUrl));
const playlistInfos = useObservable<BPList>(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 (
<div className="relative w-screen h-screen overflow-hidden">
{playlist && <BsmImage className="absolute top-0 left-0 w-full h-full" image={playlist.playlistImage} />}
{playlistInfos && <BsmImage className="absolute top-0 left-0 w-full h-full" placeholder={defaultImage} image={playlistImage()} errorImage={defaultImage} />}
<div className="w-full h-full backdrop-brightness-50 backdrop-blur-md flex flex-col justify-start items-center">
<TitleBar template="oneclick-download-playlist.html" />
<BsmImage className="mt-2 aspect-square w-1/2 object-cover rounded-md shadow-black shadow-lg" placeholder={defaultImage} image={cover} errorImage={defaultImage} />
<h1 className="mt-4 overflow-hidden font-bold italic text-xl text-gray-200 tracking-wide w-full text-center whitespace-nowrap text-ellipsis px-2">{title}</h1>
<BsmImage className="mt-2 aspect-square w-1/2 object-cover rounded-md shadow-black shadow-lg" placeholder={defaultImage} image={playlistImage()} errorImage={defaultImage} />
<h1 className="mt-4 overflow-hidden font-bold italic text-xl text-gray-200 tracking-wide w-full text-center whitespace-nowrap text-ellipsis px-2">{playlistInfos?.playlistTitle}</h1>
<div className="w-full py-3 flex items-center justify-center max-w-full overflow-x-scroll overflow-y-hidden scrollbar scrollbar-thin scrollbar-track-transparent scrollbar-thumb-neutral-900" ref={mapsContainer}>
<div className="flex justify-start items-start gap-2.5">{downloadedMap?.map(map => map?.versions?.at(0)?.coverURL && <motion.img layout="position" key={map.id} className="block aspect-square w-14 object-cover rounded-md shadow-black shadow-md" src={map?.versions?.at(0)?.coverURL} initial={{ scale: 0 }} animate={{ scale: 1 }} whileHover={{ rotate: 5 }} />)}</div>
<div className="flex justify-start items-start gap-2.5">{downloadedMaps?.map(map => map?.coverUrl &&
<motion.img layout="position" key={map.hash} className="block aspect-square w-14 object-cover rounded-md shadow-black shadow-md" src={map?.coverUrl} initial={{ scale: 0 }} animate={{ scale: 1 }} whileHover={{ rotate: 5 }} />
)}</div>
</div>
</div>
<BsmProgressBar />
-1
View File
@@ -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";
@@ -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;
}