diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index 29bf8ae3..3e1e7336 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -1,6 +1,10 @@ +import { LocalBPList } from "shared/models/playlists/local-playlist.models"; import { LocalPlaylistsManagerService } from "../services/additional-content/local-playlists-manager.service"; import { IpcService } from "../services/ipc.service"; import { of } from "rxjs"; +import { BPList } from "shared/models/playlists/playlist.interface"; +import path from "path"; +import { pathToFileURL } from "url"; const ipc = IpcService.getInstance(); @@ -27,11 +31,26 @@ ipc.on("is-playlists-deep-links-enabled", (args, reply) => { ipc.on("install-playlist", (args, reply) => { const playlists = LocalPlaylistsManagerService.getInstance(); - if(args.playlist?.customData?.syncURL){ - return reply(playlists.downloadPlaylist(args.playlist.customData.syncURL, args.version)); + const isLocalBPList = (bpList: BPList): bpList is LocalBPList => { + return (bpList as LocalBPList).path !== undefined && path.isAbsolute((bpList as LocalBPList).path); } - reply(playlists.downloadPlaylistSongs(args.playlist.songs, args.version)); + const playListUrl = (() => { + if (args.playlist.customData?.syncURL) { + return args.playlist.customData.syncURL; + } + if (isLocalBPList(args.playlist)) { + return args.playlist.path; + } + return undefined; + })(); + + return reply(playlists.downloadPlaylist({ + bpListUrl: playListUrl, + version: args.version, + ignoreSongsHashs: args.ignoreSongsHashs, + dest: (args.playlist as LocalBPList)?.path + })); }); diff --git a/src/main/ipcs/os-controls-ipcs.ts b/src/main/ipcs/os-controls-ipcs.ts index 6d59e706..7e2e264a 100644 --- a/src/main/ipcs/os-controls-ipcs.ts +++ b/src/main/ipcs/os-controls-ipcs.ts @@ -41,3 +41,7 @@ ipc.on("notify-system", (args, reply) => { const systemNotification = NotificationService.getInstance(); reply(of(systemNotification.notify(args))); }); + +ipc.on("view-path-in-explorer", (args, reply) => { + reply(of(shell.showItemInFolder(args))); +}); 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 cea24bf8..b23249cf 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -1,5 +1,5 @@ import path from "path"; -import { Observable, lastValueFrom, tap } from "rxjs"; +import { Observable, Subject, from, lastValueFrom, mergeMap, take, takeUntil, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { BSLocalVersionService } from "../bs-local-version.service"; import { DeepLinkService } from "../deep-link.service"; @@ -10,7 +10,7 @@ import { WindowManagerService } from "../window-manager.service"; import { BPList, DownloadPlaylistProgressionData, PlaylistSong } from "shared/models/playlists/playlist.interface"; import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; -import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra"; +import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFile, writeFileSync } from "fs-extra"; import { Progression, pathExist, unlinkPath } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; import { SongDetailsCacheService } from "./maps/song-details-cache.service"; @@ -18,6 +18,8 @@ import { sToMs } from "shared/helpers/time.helpers"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { SongCacheService } from "./maps/song-cache.service"; import { InstallationLocationService } from "../installation-location.service"; +import sanitize from "sanitize-filename"; +import { isValidUrl } from "shared/helpers/url.helpers"; export class LocalPlaylistsManagerService { private static instance: LocalPlaylistsManagerService; @@ -80,27 +82,37 @@ export class LocalPlaylistsManagerService { return fullPath; } - 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); + private async installBPListFile(opt: { + bslistSource: string, + version?: BSVersion, + dest?: string + }): Promise<{path: string, localBPList: LocalBPList}> { + const bplist = await this.readPlaylistFromSource(opt.bslistSource); - if (isLocalFile) { - return copyFile(bslistSource, destFile).then(() => destFile); - } - return lastValueFrom(this.request.downloadFile(bslistSource, destFile)).then(res => res.data); + const dest = await (async () => { + if(opt.dest && path.isAbsolute(opt.dest) && path.extname(opt.dest) === ".bplist") { return opt.dest; } + const playlistFolder = await this.getPlaylistsFolder(opt.version); + return path.join(playlistFolder, `${sanitize(bplist.playlistTitle)}.bplist`); + })(); + + writeFileSync(dest, JSON.stringify(bplist, null, 2)); + + const localBPList: LocalBPList = { ...bplist, path: dest }; + + return { path: dest, localBPList }; } - private async readPlaylistFile(filePath: string): Promise { - if (!(await pathExist(filePath))) { - throw new Error(`bplist file not exist at ${filePath}`); + private async readPlaylistFromSource(source: string): Promise { + const isLocalFile = await pathExists(source).catch(e => { log.error(e); return false; }); + + if(!isLocalFile && !isValidUrl(source)) { + throw new Error(`Invalid source ${source}`); } - const rawContent = readFileSync(filePath).toString(); + const bpList = isLocalFile ? JSON.parse(readFileSync(source).toString()) : await this.request.getJSON(source); - return JSON.parse(rawContent); + return bpList; } private openOneClickDownloadPlaylistWindow(downloadUrl: string): void { @@ -124,7 +136,7 @@ export class LocalPlaylistsManagerService { for (const playlist of playlists) { const playlistPath = path.join(folerPath, playlist); - const bpList = await this.readPlaylistFile(playlistPath); + const bpList = await this.readPlaylistFromSource(playlistPath); const localBpList: LocalBPList = { ...bpList, path: playlistPath }; @@ -139,6 +151,32 @@ export class LocalPlaylistsManagerService { }); } + public getLocalBPListDetails(localBPList: LocalBPList): LocalBPListsDetails { + + const tryExtractPlaylistId = (url: string) => { + const regex = /\/id\/(\d+)\/download/; + const match = url.match(regex); + return match ? Number(match[1]) : undefined; + } + + const bpListDetails: LocalBPListsDetails = { + ...localBPList, + nbMaps: localBPList.songs?.length ?? 0, + id: localBPList.customData?.syncURL ? tryExtractPlaylistId(localBPList.customData.syncURL) : undefined + } + + const songsDetails = localBPList.songs?.map(s => this.songDetails.getSongDetails(s.hash)); + + if(songsDetails){ + bpListDetails.duration = songsDetails.reduce((acc, song) => acc + song.duration, 0); + bpListDetails.nbMappers = new Set(songsDetails.map(s => s.uploader.id)).size; + bpListDetails.minNps = Math.min(...songsDetails.map(s => Math.min(...s.difficulties.map(d => d.nps || 0)))); + bpListDetails.maxNps = Math.max(...songsDetails.map(s => Math.max(...s.difficulties.map(d => d.nps || 0)))); + } + + return bpListDetails; + } + public getVersionPlaylistsDetails(version: BSVersion): Observable> { return new Observable>(obs => { @@ -151,31 +189,9 @@ export class LocalPlaylistsManagerService { await this.songDetails.waitLoaded(sToMs(15)); - const tryExtractPlaylistId = (url: string) => { - const regex = /\/id\/(\d+)\/download/; - const match = url.match(regex); - return match ? Number(match[1]) : undefined; - } - const bpListsDetails: LocalBPListsDetails[] = []; for(const bpList of localBPListsRes.data){ - - const bpListDetails: LocalBPListsDetails = { - ...bpList, - nbMaps: bpList.songs?.length ?? 0, - id: bpList.customData?.syncURL ? tryExtractPlaylistId(bpList.customData.syncURL) : undefined - } - - const songsDetails = bpList.songs?.map(s => this.songDetails.getSongDetails(s.hash)); - - if(songsDetails){ - bpListDetails.duration = songsDetails.reduce((acc, song) => acc + song.duration, 0); - bpListDetails.nbMappers = new Set(songsDetails.map(s => s.uploader.id)).size; - bpListDetails.minNps = Math.min(...songsDetails.map(s => Math.min(...s.difficulties.map(d => d.nps || 0)))); - bpListDetails.maxNps = Math.max(...songsDetails.map(s => Math.max(...s.difficulties.map(d => d.nps || 0)))); - } - - bpListsDetails.push(bpListDetails); + bpListsDetails.push(this.getLocalBPListDetails(bpList)); } obs.next({...localBPListsRes, data: bpListsDetails}); @@ -185,23 +201,34 @@ export class LocalPlaylistsManagerService { }); } - public downloadPlaylistSongs(bpList: PlaylistSong[], version: BSVersion): Observable> { + public downloadPlaylistSongs(localBPList: LocalBPList, ignoreSongsHashs: string[] = [], version: BSVersion): Observable> { + + let destroyed = false; + return new Observable>(obs => { (async () => { const progress: Progression = { - total: bpList.length, + total: localBPList.songs.length, current: 0, data: { downloadedMaps: [], currentDownload: null, - playlistInfos: null, - playlistPath: null, + playlist: this.getLocalBPListDetails(localBPList), } }; obs.next(progress); - for (const song of bpList) { + for (const song of localBPList.songs) { + + if(destroyed) { break; } + + if(ignoreSongsHashs.includes(song.hash)) { + progress.current += 1; + obs.next(progress); + continue; + } + const [ mapDetail ] = await this.bsaver.getMapDetailsFromHashs([song.hash]); if(!mapDetail) { @@ -219,24 +246,40 @@ export class LocalPlaylistsManagerService { })() .catch(err => obs.error(err)) .finally(() => obs.complete()); + + return () => { + destroyed = true; + } }); } - public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable> { + public downloadPlaylist({ bpListUrl, version, ignoreSongsHashs = [], dest }: { + bpListUrl: string, + version?: BSVersion + ignoreSongsHashs?: string[] + dest?: string + }): Observable> { + + const destroyed$ = new Subject() return new Observable>(obs => { (async () => { - const bpListFilePath = await this.installBPListFile(bpListUrl, version); - const bpList = await this.readPlaylistFile(bpListFilePath); + const { localBPList } = await this.installBPListFile({ bslistSource: bpListUrl, version, dest }); - await lastValueFrom(this.downloadPlaylistSongs(bpList.songs, version).pipe( + await lastValueFrom(this.downloadPlaylistSongs(localBPList, ignoreSongsHashs, version).pipe( tap({ next: p => obs.next(p) }), + takeUntil(destroyed$), )); })() .catch(err => obs.error(err)) .finally(() => obs.complete()); + + return () => { + destroyed$.next(); + destroyed$.complete(); + } }); } @@ -246,7 +289,7 @@ export class LocalPlaylistsManagerService { return new Observable(obs => { (async () => { - const bpList = await this.readPlaylistFile(opt.path); + const bpList = await this.readPlaylistFromSource(opt.path); const progress: Progression = { current: 0, total: opt.deleteMaps ? bpList.songs.length + 1 : 1}; @@ -277,19 +320,19 @@ export class LocalPlaylistsManagerService { (async () => { const versions = await this.versions.getInstalledVersions(); - const download$ = this.downloadPlaylist(bpListUrl, versions.pop()).pipe(tap({ + const download$ = this.downloadPlaylist({ bpListUrl, version: versions.pop() }).pipe(tap({ next: progress => obs.next(progress), error: err => obs.error(err), })); - const { data: {downloadedMaps, playlistPath} } = await lastValueFrom(download$); + const { data: {downloadedMaps, playlist} } = await lastValueFrom(download$); - if(downloadedMaps?.length === 0 || !playlistPath) { return; } + if(downloadedMaps?.length === 0 || !playlist.path) { return; } const realSourceMapsFolder = await realpath(path.dirname(downloadedMaps[0].path)); for (const version of versions) { - await this.installBPListFile(playlistPath, version); + await this.installBPListFile({ bslistSource: playlist.path, version}); const versionMapsFolder = await this.maps.getMapsFolderPath(version); const realDestMapsFolder = await realpath(versionMapsFolder).catch(e => { diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index fa03ff75..47c494b8 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -268,6 +268,8 @@ export class LocalMapsManagerService { throw new Error("Cannot download map, no hash found"); } + log.info("Downloading map", map.name, map.id); + const zipUrl = map.versions.at(0).downloadURL; const mapFolderName = sanitize(`${map.id}-${map.name}`); const mapsFolder = await this.getMapsFolderPath(version); diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 48083ec4..d8e2be2c 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -29,7 +29,7 @@ type Props = { export const InstalledMapsContext = createContext<{ maps$?: BehaviorSubject; setMaps: (maps: BsmLocalMap[]) => void; - playlists$?: Observable; + playlists$?: BehaviorSubject; setPlaylists: (playlist: LocalBPListsDetails[]) => void; }>(null); diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index e79931d5..0d7c45dd 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -6,10 +6,10 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useService } from "renderer/hooks/use-service.hook"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; -import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, merge, mergeAll, of, pipe, tap } from "rxjs"; +import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { noop } from "shared/helpers/function.helpers"; -import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; +import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { PlaylistItem } from "./playlist-item.component"; import { useStateMap } from "renderer/hooks/use-state-map.hook"; import { ModalService } from "renderer/services/modale.service"; @@ -18,6 +18,9 @@ import { InstalledMapsContext } from "../maps-playlists-panel.component"; import { IpcService } from "renderer/services/ipc.service"; import equal from "fast-deep-equal"; import { useObservable } from "renderer/hooks/use-observable.hook"; +import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; +import { ProgressBarService } from "renderer/services/progress-bar.service"; +import { NotificationService } from "renderer/services/notification.service"; type Props = { version: BSVersion; @@ -29,8 +32,11 @@ type Props = { export const LocalPlaylistsListPanel = forwardRef(({ version, className, isActive, linkedState }, forwardedRef) => { const playlistService = useService(PlaylistsManagerService); + const playlistDownloader = useService(PlaylistDownloaderService); const modals = useService(ModalService); const ipc = useService(IpcService); + const progress = useService(ProgressBarService); + const notification = useService(NotificationService); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); @@ -65,10 +71,44 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl loadPercent$.next(0); }); + const onPlaylistDownloadedCB = (downloaded: LocalBPListsDetails) => { + const newPlaylist = (() => { + console.log(playlists); + const index = playlists$.value.findIndex(p => p.path === downloaded.path); + if(index === -1){ + return [...playlists$.value, downloaded]; + } + + const newPlaylists = [...playlists$.value]; + newPlaylists[index] = downloaded; + return newPlaylists; + })(); + setPlaylists(newPlaylist); + } + + playlistDownloader.addOnPlaylistDownloadedListener(version, onPlaylistDownloadedCB); + + return () => { + playlistDownloader.removeOnPlaylistDownloadedListener(version, onPlaylistDownloadedCB); + } + }, [isActiveOnce, version, linked]); + const installPlaylist = (playlist: LocalBPList) => { + + const ignoreSongsHashs = (maps$.value || []).map(m => m.hash.toLocaleLowerCase()); + + const obs$ = playlistDownloader.installPlaylist(playlist, version, ignoreSongsHashs); + + return lastValueFrom(obs$).then(res => { + if(res.current === res.total){ + notification.notifySuccess({ title: "Playlist synchronisée !", desc: "La playlist et ses maps on été téléchargées.", duration: 5000 }) + } + }); + } + const viewPlaylistFile = (path: string) => { - return lastValueFrom(ipc.sendV2("view-path-in-explorer", { args: path })); + return lastValueFrom(ipc.sendV2("view-path-in-explorer", path)); }; const deletePlaylist = (path: string) => { @@ -115,7 +155,7 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl minNps={p.minNps} onClickOpen={() => openPlaylistDetails(p.path)} onClickDelete={() => deletePlaylist(p.path)} - onClickSync={() => console.log("sync")} + onClickSync={() => installPlaylist(p)} onClickOpenFile={() => viewPlaylistFile(p.path)} /> )} diff --git a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx index edaf1d80..6db9945f 100644 --- a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx @@ -1,6 +1,6 @@ import { ModalComponent } from "renderer/services/modale.service" import { PlaylistDetailsTemplate, PlaylistDetailsTemplateProps } from "./playlist-details-template.component" -import { Observable, first, lastValueFrom, map, shareReplay, switchMap, take, tap } from "rxjs" +import { Observable, combineLatest, first, lastValueFrom, map, mergeAll, mergeMap, shareReplay, switchMap, take, tap } from "rxjs" import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface" import { BSVersion } from "shared/bs-version.interface"; import { useObservable } from "renderer/hooks/use-observable.hook"; @@ -14,7 +14,10 @@ import { BsmButton } from "renderer/components/shared/bsm-button.component"; import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; -import { ProgressBarService } from "renderer/services/progress-bar.service"; +import { PlaylistHeaderState } from "./playlist-header-state.component"; +import { useConstant } from "renderer/hooks/use-constant.hook"; + +// TODO : Translate interface Props { version: BSVersion; @@ -26,11 +29,16 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver const audioPlayer = useService(AudioPlayerService); const playlistDownloader = useService(PlaylistDownloaderService); - const progressBar = useService(ProgressBarService); const localPlaylist = useObservable(() => options.data.localPlaylist$, null); const installedMaps = useObservable(() => options.data.installedMaps$, null); + const isMissingMaps$ = useConstant(() => combineLatest([options.data.installedMaps$, options.data.localPlaylist$]).pipe(map(([maps, playlist]) => maps.length !== playlist.songs.length))); + const isPlaylistDownloading$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistDownloading(playlist, options.data.version)))); + const isPlaylistInQueue$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistInQueue(playlist, options.data.version)))); + + const isInQueue = useObservable(() => isPlaylistInQueue$, false); + const playPlaylist = () => { if (!installedMaps) { return; @@ -39,55 +47,54 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver }; const installPlaylist = () => { - const obs$ = playlistDownloader.installPlaylist(localPlaylist, options.data.version); - const progress$ = obs$.pipe( - map(progress => (progress.current / progress.total) * 100), - ); + const ignoreSongsHashs = installedMaps?.map(map => map.hash); - const obsWithProgress$ = obs$.pipe( - take(1), - tap(() => progressBar.show(progress$, true)), - switchMap(() => obs$), - tap({ complete: () => progressBar.hide(true) }) - ); + const obs$ = playlistDownloader.installPlaylist(localPlaylist, options.data.version, ignoreSongsHashs); - return lastValueFrom(obsWithProgress$); + return lastValueFrom(obs$); } const renderMaps = () => { - if (!installedMaps) { - // loading maps - return null; + if (!Array.isArray(installedMaps) && !isInQueue) { + return ( +
+ +
+ ); } - if(installedMaps.length === 0) { - // no maps - return null; + if(installedMaps.length === 0 && !isInQueue) { + return ( +
+ +
+

Aucune maps installée pour cette playlist

+ +
+
+ ); + } + + if(installedMaps.length === 0 && isInQueue) { + return ( +
+ +
+

La Playlist est en attente de téléchargment

+
+
+ ); } return (
- - {/* If nb installed maps not correspond to nb maps of the playlist */} - {installedMaps.length !== localPlaylist.nbMaps && ( - -
- -
-

Certaines maps de cette playlist sont manquantes

- -
-
-
- )} -
+
    {installedMaps.map(map => ( ; + isPlaylistDownloading$: Observable; + isMissingMaps$: Observable; + installPlaylist: () => void; +} + +export function PlaylistHeaderState({isPlaylistInQueue$, isPlaylistDownloading$, isMissingMaps$, installPlaylist}: Props) { + + const isPlaylistInQueue = useObservable(() => isPlaylistInQueue$, false); + const isPlaylistDownloading = useObservable(() => isPlaylistDownloading$, false); + const isMissingMaps = useObservable(() => isMissingMaps$, false); + + console.log(isPlaylistInQueue, isPlaylistDownloading, isMissingMaps); + + const renderHeaderContent = () => { + + if(isPlaylistDownloading) { + return ( + <> + +
    +

    La playlist est en cours de téléchargement

    +
    + + ) + } + + if(isPlaylistInQueue) { + return ( + <> + +
    +

    La Playlist est en attente de téléchargment

    +
    + + ) + } + + return ( + <> + +
    +

    Certaines maps de cette playlist sont manquantes

    + +
    + + ); + } + + return ( + + {(isPlaylistInQueue || isPlaylistDownloading || isMissingMaps) && +
    + {renderHeaderContent()} +
    +
    } +
    + ) +} diff --git a/src/renderer/services/bs-version-manager.service.ts b/src/renderer/services/bs-version-manager.service.ts index 986d91bb..51d480fe 100644 --- a/src/renderer/services/bs-version-manager.service.ts +++ b/src/renderer/services/bs-version-manager.service.ts @@ -162,7 +162,7 @@ export class BSVersionManagerService { shareReplay({ bufferSize: 1, refCount: true }) ); - this.progressBar.show(obs$.pipe(map(progress => (progress.current / progress.total) * 100)), true); + this.progressBar.show(obs$, true); return obs$; } diff --git a/src/renderer/services/playlist-downloader.service.ts b/src/renderer/services/playlist-downloader.service.ts index 74724ae9..a23746af 100644 --- a/src/renderer/services/playlist-downloader.service.ts +++ b/src/renderer/services/playlist-downloader.service.ts @@ -1,10 +1,11 @@ -import { BehaviorSubject, Observable, filter, lastValueFrom, map, shareReplay, take, tap } from "rxjs"; +import { BehaviorSubject, Observable, Subscription, distinctUntilChanged, filter, lastValueFrom, map, shareReplay, take, takeUntil, takeWhile, tap } from "rxjs"; import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; import { IpcService } from "./ipc.service"; import { ProgressBarService } from "./progress-bar.service"; import { Progression } from "main/helpers/fs.helpers"; import equal from "fast-deep-equal"; import { BSVersion } from "shared/bs-version.interface"; +import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; export class PlaylistDownloaderService { private static instance: PlaylistDownloaderService; @@ -20,32 +21,82 @@ export class PlaylistDownloaderService { private readonly progress: ProgressBarService; private readonly ipc: IpcService; - private readonly playlistQueue$ = new BehaviorSubject([]); + private readonly canceled$ = new BehaviorSubject(false); + private readonly playlistQueue$ = new BehaviorSubject([]); + private readonly onPlaylistDownloadedListeners = new Map void)[]>(); private constructor() { this.progress = ProgressBarService.getInstance(); this.ipc = IpcService.getInstance(); } - public installPlaylist(bpList: BPList, version?: BSVersion): Observable> { - this.playlistQueue$.next([...this.playlistQueue$.value, bpList]); + public installPlaylist(bpList: BPList, version?: BSVersion, ignoreSongsHashs?: string[]): Observable> { - const clear = () => { - this.playlistQueue$.next(this.playlistQueue$.value.filter(p => p !== bpList)); - } + this.playlistQueue$.next([...this.playlistQueue$.value, { version, source: bpList }]); + + console.log("AAAAA"); return new Observable>(subscriber => { - (async () => { - const playlist = await lastValueFrom(this.playlistQueue$.pipe(map(queue => queue.at(0)), filter(p => equal(bpList, p)), take(1))); - const download$ = this.ipc.sendV2("install-playlist", { version, playlist }); - await lastValueFrom(download$.pipe(tap(subscriber))); + let subs: Subscription[] = []; + + (async () => { + const queueInfo = await lastValueFrom(this.playlistQueue$.pipe(map(queue => queue.at(0)), filter(p => equal(bpList, p.source)), take(1))); + + console.log(queueInfo); + + const download$ = this.ipc.sendV2("install-playlist", { version: queueInfo.version, playlist: queueInfo.source, ignoreSongsHashs }).pipe(takeWhile(() => !this.canceled$.value)); + + subs.push(download$.pipe(map(progress => progress?.data?.playlist), filter(Boolean), take(1)).subscribe(playlist => { + queueInfo.downloaded = playlist; + this.onPlaylistDownloadedListeners.get(version)?.forEach(cb => cb(playlist)); + })); + + const canShowProgress = !this.progress.isVisible; + if (canShowProgress) { + this.progress.show(download$.pipe(map(data => (data.current / data.total) * 100)), true); + } + + await lastValueFrom(download$.pipe(tap(subscriber))).finally(() => { + if (canShowProgress) { + this.progress.hide(true); + } + }) })() .then(() => subscriber.complete()) .catch(err => subscriber.error(err)) - .finally(clear); + .finally(() => { + this.playlistQueue$.next(this.playlistQueue$.value.filter(p => !equal(p.version, version) || !equal(p.source, bpList))); + this.canceled$.next(false); + }); - }).pipe(shareReplay(1)) + return () => subs.forEach(s => s.unsubscribe()); + + }).pipe(shareReplay(1)); + } + + public cancelCurrentDownload() { + this.canceled$.next(true); + } + + public get currentDownloading$(): Observable { + return this.playlistQueue$.pipe(map(queue => queue.at(0)), distinctUntilChanged(equal)); + } + + public $isPlaylistInQueue(bpList: BPList, version?: BSVersion): Observable { + return this.playlistQueue$.pipe(map(queue => queue.some(p => p && (equal(p.source, bpList) || equal(p.downloaded, bpList)) && equal(p.version, version))), distinctUntilChanged()); + } + + public $isPlaylistDownloading(bpList: BPList, version?: BSVersion): Observable { + return this.currentDownloading$.pipe(map(p => p && (equal(p.source, bpList) || equal(p.downloaded, bpList)) && equal(p.version, version)), distinctUntilChanged()); + } + + public addOnPlaylistDownloadedListener(version: BSVersion, cb: (playlist: LocalBPListsDetails) => void){ + this.onPlaylistDownloadedListeners.set(version, [...(this.onPlaylistDownloadedListeners.get(version) || []), cb]); + } + + public removeOnPlaylistDownloadedListener(version: BSVersion, cb: (playlist: LocalBPListsDetails) => void){ + this.onPlaylistDownloadedListeners.set(version, (this.onPlaylistDownloadedListeners.get(version) || []).filter(c => c !== cb)); } public oneClickInstallPlaylist(bpListUrl: string): Observable> { @@ -61,3 +112,9 @@ export class PlaylistDownloaderService { })); } } + +export type PlaylistQueueInfo = { + version?: BSVersion; + source: BPList; + downloaded?: LocalBPListsDetails; +} diff --git a/src/renderer/services/progress-bar.service.ts b/src/renderer/services/progress-bar.service.ts index 6fe6ba73..dfd746b7 100644 --- a/src/renderer/services/progress-bar.service.ts +++ b/src/renderer/services/progress-bar.service.ts @@ -4,6 +4,8 @@ import { IpcService } from "./ipc.service"; import { NotificationService } from "./notification.service"; import { CSSProperties } from "react"; import { ProgressionInterface } from "shared/models/progress-bar"; +import { Progression } from "main/helpers/fs.helpers"; +import { satisfies } from "semver"; export class ProgressBarService { private static instance: ProgressBarService; @@ -39,7 +41,7 @@ export class ProgressBarService { lastValueFrom(this.ipcService.sendV2("window.progression", progression)); } - public subscribreTo(obs: Observable) { + public subscribreTo(obs: Observable) { if (this.subscription) { this.unsubscribe(); } @@ -47,6 +49,16 @@ export class ProgressBarService { if (typeof value === "number") { return this._progression$.next({ progression: value }); } + + const isProgression = (progression: unknown): progression is Progression => { + return typeof (progression as Progression).current === "number" && typeof (progression as Progression).total === "number"; + } + + if (isProgression(value)) { + const progress = (value as Progression).current / (value as Progression).total * 100; + return this._progression$.next({ progression: Math.floor(progress)}); + } + this._progression$.next(value); }); } @@ -57,7 +69,7 @@ export class ProgressBarService { this.subscription = null; } - public show(obs?: Observable, unsubscribe?: boolean, style?: CSSProperties) { + public show(obs?: Observable, unsubscribe?: boolean, style?: CSSProperties) { if (unsubscribe) { this.unsubscribe(); } diff --git a/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx b/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx index 2cc33340..7af239bf 100644 --- a/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx +++ b/src/renderer/windows/OneClick/OneClickDownloadPlaylist.tsx @@ -27,7 +27,7 @@ export default function OneClickDownloadPlaylist() { const mapsContainer = useRef(null); const { playlistUrl } = useWindowArgs("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 playlistInfos = useObservable(() => download$.pipe(filter(progress => !!progress.data?.playlist), map(progress => progress.data.playlist), take(1))); const downloadedMaps = useObservable(() => download$.pipe(filter(progress => !!progress.data?.downloadedMaps), map(progress => progress.data.downloadedMaps))); useEffect(() => { diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 8a33bd8f..12dd89d0 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -80,7 +80,7 @@ export interface IpcChannelMapping extends Record}; + "install-playlist": {request: {playlist: BPList, version?: BSVersion, ignoreSongsHashs?: string[]}, response: Progression}; "get-version-playlists-details": {request: BSVersion, response: Progression}; "delete-playlist": {request: {path: string, deleteMaps?: boolean}, response: Progression}; @@ -117,6 +117,7 @@ export interface IpcChannelMapping extends Record { playlistTitle: string; @@ -21,8 +22,7 @@ export interface PlaylistSong { export interface DownloadPlaylistProgressionData { downloadedMaps: BsmLocalMap[]; currentDownload: BsvMapDetail; - playlistInfos: BPList; - playlistPath: string; + playlist: LocalBPListsDetails; } export interface CustomDataBPList {