From 6328d6a96cdf62375384d4d20b35df97e0bdb5f3 Mon Sep 17 00:00:00 2001 From: FeliciaWen Date: Tue, 28 Feb 2023 03:52:31 +0800 Subject: [PATCH] Enhancements: Bring back Spotify support. Bring back interactive prompt. Add support for bookmarking on BeastBaber. Improve BeautifulSoup navigation. --- .gitignore | 5 ++ pysaber/saberio.py | 60 ++++++++++++++------ pysaber/utils/helpers.py | 60 ++++++++++++-------- pysaber/utils/spotify.py | 117 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + setup.py | 4 +- 6 files changed, 207 insertions(+), 41 deletions(-) create mode 100644 .gitignore create mode 100644 pysaber/utils/spotify.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbd427f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +saberio.egg-info/ +pysaber/__pycache__ +pysaber/utils/__pycache__ +.cache +pysaber/utils/config.json diff --git a/pysaber/saberio.py b/pysaber/saberio.py index 977e838..0658cf2 100644 --- a/pysaber/saberio.py +++ b/pysaber/saberio.py @@ -1,10 +1,12 @@ from argparse import ArgumentParser -from os import mkdir, path +from os import makedirs, path from colorifix.colorifix import paint, ppaint from pymortafix.utils import multisub, strict_input from pysaber.utils.helpers import (SPINNER, download_song, get_info_by_code, - look_for_code, search_songs, songs_table) + look_for_code, search_songs, songs_table, + bookmark_song) +from pysaber.utils.spotify import(retrieve_params, get_spotify_songs) def argparsing(): @@ -29,6 +31,16 @@ def argparsing(): search.add_argument( "-f", "--file", type=str, help="text file with a songs list", metavar=("SONG") ) + bookmark = parser.add_argument_group() + bookmark.add_argument( + "-c", "--cookie", type=str, help="cookie for bookmarking." + ) + bookmark.add_argument( + '-b', action="store_true", help="bookmark all songs searched from BeastSaber." + ) + bookmark.add_argument( + "-o", action="store_true", help="bookmark songs without downloading." + ) mode = parser.add_mutually_exclusive_group() mode.add_argument( "--auto", @@ -55,7 +67,15 @@ def argparsing(): def main(): args = argparsing() + spotify_playlist_link, playlist_name = retrieve_params(args) + # default + path_to_folder = args.dir and path.dirname(path.join((args.dir or "."), playlist_name)) or '.' + playlist_name = args.p or "songs" + automatic = not args.list or args.auto or args.test + is_test = args.test or False + mode_name = (is_test and "test") or (automatic and "auto") or "list" + # check if args.dir and not path.exists(path.join(args.dir)): ppaint(f"[#red]Path [@underline]{args.dir}[/@] doesn't exist![/]") @@ -64,18 +84,6 @@ def main(): ppaint(f"[#red]File [@underline]{args.file}[/@] doesn't exist![/]") exit(-1) - # default - path_to_folder = args.dir or "." - playlist_name = args.p or "songs" - automatic = not args.list or args.auto or args.test - is_test = args.test or False - mode_name = (is_test and "test") or (automatic and "auto") or "list" - ppaint( - f"> Folder: [#gray @bold]{path_to_folder}[/]\n" - f"> Playlist: [#gray @bold]{playlist_name}[/]\n" - f"> Mode: [#gray @bold]{mode_name}[/]" - ) - # param: songs songs = list() if args.file: @@ -86,8 +94,18 @@ def main(): elif args.song: songs = [look_for_code(args.song)] ppaint(f"> Search: [#gray @bold]{args.song}[/]") - if not is_test and not path.exists(path.join(path_to_folder, playlist_name)): - mkdir(path.join(path_to_folder, playlist_name)) + elif spotify_playlist_link != None: + songs = [(None, f"{track} {artist}") for track, artist in get_spotify_songs(spotify_playlist_link)] + + + ppaint( + f"> Folder: [#gray @bold]{path_to_folder}[/]\n" + f"> Playlist: [#gray @bold]{playlist_name}[/]\n" + f"> Mode: [#gray @bold]{mode_name}[/]" + ) + + if not is_test and not args.o and not path.exists(path.join(path_to_folder, playlist_name)): + makedirs(path.join(path_to_folder, playlist_name)) print() # searching @@ -98,7 +116,7 @@ def main(): SPINNER.succeed(paint(f"Search complete for [#blue]{song_more}[/]")) if bsaber_songs: bsaber_songs = sorted( - bsaber_songs, key=lambda x: (-x[4] + 1) / (x[5] + 1) + bsaber_songs, key=lambda x: (-x[5] + 1) / (x[6] + 1) ) n = 1 if not automatic: @@ -106,10 +124,16 @@ def main(): n = strict_input( paint("> Choose a song: [@underline][0:skip][/] "), choices=list(map(str, range(len(bsaber_songs) + 1))), - flush=True, + flush=True ) if int(n) > 0: song_to_download = bsaber_songs[int(n) - 1] + if args.cookie and (args.b or strict_input( + paint('> Whether to add to the bookmark? [Y/n]'), + choices=['y', 'n', 'Y', 'N', ''], + flush=True) in ('y', 'Y', '')): + bookmark_song(song_to_download[3], song_to_download[1], args.cookie) + if args.o: continue else: SPINNER.fail(paint(f"Skipped [#blue]{song_more}[/]")) else: diff --git a/pysaber/utils/helpers.py b/pysaber/utils/helpers.py index e298a7f..7e3e58f 100644 --- a/pysaber/utils/helpers.py +++ b/pysaber/utils/helpers.py @@ -4,21 +4,14 @@ from bs4 import BeautifulSoup as bs from colorifix.colorifix import paint from halo import Halo -from requests import get +from requests import get, post from tabulate import tabulate SPINNER = Halo() -MAX_CHAR = 45 # ---- Utils -def lines_splitting(name): - if len(name) <= MAX_CHAR: - return paint(f"[#white]{name}[/]") - return paint(f"[#white]{name[:MAX_CHAR]}[/]\n{lines_splitting(name[MAX_CHAR:])}") - - def songs_table(songs): fields = ["", "Code", "Song", "Mapper", "Up", "Down", "Difficulty", "Date"] headers = [paint(f"[@bold]{field}[/]") for field in fields] @@ -26,16 +19,16 @@ def songs_table(songs): ( i, paint(f"[#magenta]{code}[/]"), - lines_splitting(name), + paint(name), paint(f"[#blue]{mapper}[/]"), paint(f"[#green]{up}[/]"), paint(f"[#red]{down}[/]"), paint(f"[#yellow]{diff}[/]"), paint(f"[#gray]{date:%d.%m.%Y}[/]"), ) - for i, (code, name, _, diff, up, down, mapper, date) in enumerate(songs, 1) + for i, (code, name, _, _, diff, up, down, mapper, date) in enumerate(songs, 1) ] - return tabulate(entries, headers=headers, tablefmt="fancy_grid") + return tabulate(entries, headers=headers, tablefmt="fancy_grid", maxcolwidths=[None, None, 45]) def look_for_code(song): @@ -51,29 +44,28 @@ def search_songs(query): SPINNER.start(paint(f"Searching for [#blue]{query}[/]")) query = sub(r"\s+", "+", query) url = f"https://bsaber.com/?s={query}&orderby=relevance&order=DESC" - songs_html = bs(get(url).text, "html.parser").findAll("div", {"class": "row"}) + songs_html = bs(get(url).text, "html.parser").find_all("div", {"class": "row"}) return [info for song in songs_html if (info := get_info(song))] - def get_info(song): + if not song.header: + return None if song.find("div", {"class": ["widget", "small-2", "subfooter-menu-holder"]}): return None - header = song.find("header") sanitize = lambda string: sub("\n", "", string).strip() - code = (m := search(r"songs/(\w+)/", header.find("a").get("href"))) and m.group(1) - title = sanitize(header.text) - difficulties_html = song.findAll("a", {"class": "post-difficulty"}) + code = song.header.a.get("href").split('/')[-2] + title = sanitize(song.header.text) + difficulties_html = song.find_all("a", {"class": "post-difficulty"}) trunc_difficulty = lambda df: df == "Expert+" and "Ex+" or df[:2] difficulties = ", ".join(trunc_difficulty(df.text) for df in difficulties_html) - stats_html = song.findAll("span", {"class": "post-stat"})[1:] - stats = [int(sanitize(s.text)) for s in stats_html] - upvote, downvote = stats or (0, 0) + upvote = int(song.find("i", class_="fa fa-thumbs-up fa-fw").next_sibling) + downvote = int(song.find("i", class_="fa fa-thumbs-down fa-fw").next_sibling) mapper_html = song.find("div", {"class": "post-bottom-meta post-mapper-id-meta"}) mapper = search(r"\n+(\w+)", mapper_html.text or "").group(1) date = datetime.fromisoformat(song.find("time").get("content")) dwn_link = (link := song.find("a", {"class": "-download-zip"})) and link.get("href") - return code, title, dwn_link, difficulties, upvote, downvote, mapper, date - + bookmark_id = (id := song.find("a", {"class": "-bookmark"})) and id.get("data-id") + return code, title, dwn_link, bookmark_id, difficulties, upvote, downvote, mapper, date def get_info_by_code(code): soup = bs(get(f"https://bsaber.com/songs/{code}").text, "html.parser") @@ -90,3 +82,27 @@ def download_song(song_name, link, filename): zip_song = get(link) open(filename, "wb").write(zip_song.content) SPINNER.succeed(paint(f"Downloaded [#blue]{song_name}[/]")) + +def bookmark_song(id, song_name, cookie): + if '200' in (ret := str(post("https://bsaber.com/wp-admin/admin-ajax.php", + headers={ + "Host": "bsaber.com", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "Referer": "https://bsaber.com/?s=halo&orderby=relevance&order=DESC", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Content-Length": "61", + "Origin": "https://bsaber.com", + "Connection": "keep-alive", + "Cookie": cookie, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "no-cors", + "Sec-Fetch-Site": "same-origin", + }, data={"action": "bsaber_bookmark_post", + "type": "add_bookmark", + "post_id": id, + }))): SPINNER.succeed(paint(f"Bookmarked [#blue]{song_name}[/]")) + else: SPINNER.fail(paint(f"Bookmark failed [#blue]{id}[/] [#yellow]{song_name}[/] [#red]{ret.strip('')}[/]")) \ No newline at end of file diff --git a/pysaber/utils/spotify.py b/pysaber/utils/spotify.py new file mode 100644 index 0000000..c7dc806 --- /dev/null +++ b/pysaber/utils/spotify.py @@ -0,0 +1,117 @@ +from re import search +from spotipy import Spotify +from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOauthError +from colorifix.colorifix import paint +from pymortafix.utils import strict_input +from json import dump, load +from os import path +from halo import Halo +SPINNER = Halo() + + +# spotify config +def create_config(): + paint("To get a key, go to [#magenta]https://developer.spotify.com/dashboard/applications[/] and create a new application.") + client_id = input("Client ID: ") + secret_id = input("Secret ID: ") + save_config(client_id=client_id, secret_id=secret_id) + try: + while True: + try: + client = Spotify( + client_credentials_manager=SpotifyClientCredentials( + client_id, secret_id + ) + ) + client.search("test") + SPINNER.succeed("Configuration successful!") + raise StopIteration + except SpotifyOauthError: + SPINNER.fail("Configuration failed.") + create_config() + except StopIteration: + pass + +def get_config(): + while True: + try: + m = load(open(f"{path.abspath(path.dirname(__file__))}/config.json", "r")) + return m + except FileNotFoundError: create_config() + + +def save_config(**kargs): + f = f"{path.abspath(path.dirname(__file__))}/config.json" + keys = kargs + if path.exists(f): keys = get_config() | kargs + dump( + keys, open(f, "w"), indent=4 + ) + + +def sanitize_song_name(name): + return (m := search(r"^([^\(,\-\[\.]+)", name)) and m.group(1).strip() + + +def get_spotify_songs(playlist_link): + config = get_config() + while not search("https://open.spotify.com/playlist/", playlist_link): + playlist_link = input( + paint("[#red]Bad link![/] [#white]Retry[/]: ") + ) + while True: + try: + client = Spotify( + client_credentials_manager=SpotifyClientCredentials( + config.get("client_id"), config.get("secret_id") + ) + ) + playlist = client.playlist(playlist_link) + break + except SpotifyOauthError: + SPINNER.fail(paint("[#red]Error![/] Please update the configuration once.")) + create_config() + return [ + ( + sanitize_song_name(song.get("track").get("name")), # Track + song.get("track").get("artists")[0].get("name"), # Artist + ) + for song in playlist.get("tracks").get("items") + ] + + +def retrieve_params(args): + spotify_playlist_link = None + if not args.file and not args.song: + if (m := input(paint("[#cyan]Type[/] the song or the playlist path right here. [#cyan]Or Press[/] [#green]ENTER[/] to enter spotify playlist link. ❯ "))) == '': + spotify_playlist_link = input( + paint("> [#green]Spotify[/] playlist link: ") + ) + elif path.exists(m): args.file = m + else: args.song = m + if not (args.auto and args.list and args.test and args.c): + while ( + choice := input( + paint("> Choose mode: [[#red]auto[/]|[#green]list[/]|[#magenta]test[/][#yellow]|[/][#red][auto][/] [#blue]bookmark[/] [#cyan][and download][/]]. ❯ ") + ).lower() + ) not in ("auto", "list", "test") + (b := ("bookmark", "auto bookmark", + "bookmark and download", + "auto bookmark and download")): + pass + if choice in b and not args.cookie: + if type(get_config().get("cookie")) != str: + args.cookie = strict_input(paint("Cookie from [#blue]B[/]saber.com\n ❯ "), wrong_text='', flush= True) + save_config(cookie=args.cookie) + else: + args.cookie = get_config()['cookie'] + args.auto = args.b = (choice == "auto" + or choice == "auto bookmark" + or choice == "auto bookmark and download" + or choice == "test") + args.test = choice == "test" + args.list = (choice == "list" or choice == "bookmark" or choice == "bookmark and download") + args.o = (choice == "bookmark" or choice == "auto bookmark") + if not args.p and not args.test and not args.o: + playlist_name = args.p = input(paint("> Choose a name for the playlist. (songs)❯ ")) + else: playlist_name = args.p + return spotify_playlist_link, playlist_name \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1e71b47..2a03b88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,5 @@ spinners==0.0.24 tabulate==0.8.10 termcolor==2.0.1 urllib3==1.26.12 +wcwidth==0.2.6 +spotipy==2.22.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 2b52189..3231cb7 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,8 @@ "pymortafix == 0.2.2", "halo == 0.0.31", "tabulate == 0.8.10", + "wcwidth == 0.2.6", + "spotipy== 2.22.1", ], classifiers=[ "Programming Language :: Python :: 3.8", @@ -27,7 +29,7 @@ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", ], - python_requires=">=3.8", + python_requires=">=3.9", keywords=["beat saber", "bsaber", "beat saver"], package_data={"pysaber": ["utils/helpers.py"]}, entry_points={"console_scripts": ["saberio=pysaber.saberio:main"]},