diff --git a/docs/Samples.md b/docs/Samples.md new file mode 100644 index 0000000..3e918c1 --- /dev/null +++ b/docs/Samples.md @@ -0,0 +1,73 @@ +# Samples + +Sample score data from `ScoreSaberAPI.get_player_scores(player_id)`: + +```python +{'score': {'id': 85407530, + 'rank': 67, + 'baseScore': 386772, + 'modifiedScore': 386772, + 'pp': 224.1916, + 'weight': 1.794356027547591e-07, + 'modifiers': '', + 'multiplier': 1, + 'badCuts': 0, + 'missedNotes': 0, + 'maxCombo': 443, + 'fullCombo': True, + 'hmd': 0, + 'hasReplay': True, + 'timeSet': '2024-10-15T00:07:34+00:00', + 'deviceHmd': 'Vive', + 'deviceControllerLeft': 'Touch', + 'deviceControllerRight': 'Touch', + 'leaderboardPlayerInfo': None}, + 'leaderboard': {'id': 610340, + 'songHash': '75A0E9F214BA9A7C2156D6E29920F112B6423CD0', + 'songName': 'Retroscope', + 'songSubName': '', + 'songAuthorName': 'Adust Rain', + 'levelAuthorName': 'ViSi', + 'difficulty': {'leaderboardId': 610340, + 'difficulty': 1, + 'gameMode': 'SoloStandard', + 'difficultyRaw': '_Easy_SoloStandard'}, + 'maxScore': 400315, + 'createdDate': '2024-06-01T15:12:31+00:00', + 'rankedDate': '2024-10-08T11:45:57+00:00', + 'qualifiedDate': '2024-09-24T19:31:54+00:00', + 'lovedDate': None, + 'ranked': True, + 'qualified': False, + 'loved': False, + 'maxPP': -1, + 'stars': 4.52, + 'positiveModifiers': False, + 'plays': 150, + 'dailyPlays': 6, + 'coverImage': 'https://cdn.scoresaber.com/covers/75A0E9F214BA9A7C2156D6E29920F112B6423CD0.png', + 'playerScore': None, + 'difficulties': []}} +``` + +Sample score data from `BeatLeaderAPI.get_player_scores(player_id)`: + +```python +{'score': {'id': 18489479, + 'baseScore': 386772, + 'modifiedScore': 386772, + 'modifiers': '', + 'fullCombo': True, + 'maxCombo': 443, + 'missedNotes': 0, + 'badCuts': 0, + 'hmd': 512, + 'controller': 1, + 'accuracy': 0.9661691, + 'pp': 157.62836, + 'epochTime': 1728950853}, + 'leaderboard': {'id': '3d2c511', + 'songHash': '75a0e9f214ba9a7c2156d6e29920f112b6423cd0', + 'modeName': 'Standard', + 'difficulty': 1}} +``` diff --git a/src/saberlist/make.py b/src/saberlist/make.py index a611b49..4954255 100644 --- a/src/saberlist/make.py +++ b/src/saberlist/make.py @@ -309,6 +309,175 @@ def playlist_strategy_beatleader_oldscores( return playlist_data, f"beatleader_oldscores-{new_count:02d}" +def playlist_strategy_beatleader_lowest_pp( + api: BeatLeaderAPI, + song_count: int = 20 +) -> List[Dict[str, Any]]: + player_id = prompt_for_player_id() + history = load_history() + history.setdefault('beatleader_lowest_pp', {}) + history.setdefault('playlist_counts', {}) + + # Get the current count for BeatLeader lowest PP and increment it + count_key = 'beatleader_lowest_pp' + current_count = history['playlist_counts'].get(count_key, 0) + new_count = current_count + 1 + history['playlist_counts'][count_key] = new_count + + scores_data = api.get_player_scores(player_id) + all_scores = scores_data.get('playerScores', []) + if not all_scores: + logging.warning(f"No scores found for player ID {player_id} on BeatLeader.") + return [], "" + logging.debug(f"Found {len(all_scores)} scores for player ID {player_id} on BeatLeader.") + + # Filter out scores with zero PP and sort by PP in ascending order + ranked_scores = [s for s in all_scores if s.get('score', {}).get('pp', 0) > 0] + ranked_scores.sort(key=lambda x: x.get('score', {}).get('pp', float('inf'))) + + playlist_data = [] + for score_entry in ranked_scores: + if len(playlist_data) >= song_count: + break # Stop if we've reached the desired number of songs + + score = score_entry.get('score', {}) + leaderboard = score_entry.get('leaderboard', {}) + + song_hash = leaderboard.get('songHash') + difficulty_raw = int(leaderboard.get('difficulty', '')) + game_mode = leaderboard.get('modeName', 'Standard') + pp = score.get('pp', 0) + + if not song_hash or not difficulty_raw: + logging.debug(f"Skipping score due to missing song_hash or difficulty_raw: {score_entry}") + continue + + difficulty = normalize_difficulty_name(difficulty_raw) + + # avoid reusing song+difficulty + if song_hash in history['beatleader_lowest_pp'] and difficulty in history['beatleader_lowest_pp'][song_hash]: + logging.debug(f"Skipping song {song_hash} with difficulty {difficulty} as it's in history.") + continue # Skip if already used + + # Format the song data for PlaylistBuilder + song_dict = { + 'hash': song_hash, + 'difficulties': [ + { + 'name': difficulty, + 'characteristic': game_mode + } + ] + } + + # Add the song to the playlist + playlist_data.append(song_dict) + logging.debug(f"Selected song for playlist: Hash={song_hash}, Difficulty={difficulty}, PP={pp:.2f}") + + # Update history + history['beatleader_lowest_pp'].setdefault(song_hash, []).append(difficulty) + + # Log the final playlist + if not playlist_data: + logging.info("No new songs found to add to the playlist based on history for BeatLeader lowest PP.") + else: + for song in playlist_data: + song_hash = song['hash'] + difficulty = song['difficulties'][0]['name'] + logging.info(f"Song added: Hash={song_hash}, Difficulty={difficulty}") + logging.info(f"Total songs added to playlist from BeatLeader lowest PP: {len(playlist_data)}") + + save_history(history) + + return playlist_data, f"beatleader_lowest_pp-{new_count:02d}" + +def playlist_strategy_scoresaber_lowest_pp( + api: ScoreSaberAPI, + song_count: int = 20 +) -> List[Dict[str, Any]]: + """Build and format a list of songs based on lowest PP scores from ScoreSaber, avoiding reusing the same song+difficulty.""" + + player_id = prompt_for_player_id() + history = load_history() + history.setdefault('scoresaber_lowest_pp', {}) + history.setdefault('playlist_counts', {}) + + # Get the current count for ScoreSaber lowest PP and increment it + count_key = 'scoresaber_lowest_pp' + current_count = history['playlist_counts'].get(count_key, 0) + new_count = current_count + 1 + history['playlist_counts'][count_key] = new_count + + scores_data = api.get_player_scores(player_id, use_cache=True) + all_scores = scores_data.get('playerScores', []) + if not all_scores: + logging.warning(f"No scores found for player ID {player_id}.") + return [], "" + logging.debug(f"Found {len(all_scores)} scores for player ID {player_id}.") + + # Filter out scores with zero PP and sort by PP in ascending order + ranked_scores = [s for s in all_scores if s['score'].get('pp', 0) > 0] + ranked_scores.sort(key=lambda x: x['score'].get('pp', float('inf'))) + + playlist_data = [] + + for score in ranked_scores: + leaderboard = score.get('leaderboard', {}) + song_id = leaderboard.get('songHash') + difficulty_raw = leaderboard.get('difficulty', {}).get('difficultyRaw', '') + + if not song_id or not difficulty_raw: + logging.debug(f"Skipping score due to missing song_id or difficulty_raw: {score}") + continue # Skip if essential data is missing + + # Normalize the difficulty name + difficulty = normalize_difficulty_name(difficulty_raw) + game_mode = leaderboard.get('difficulty', {}).get('gameMode', 'Standard') + if 'Standard' in game_mode: + game_mode = 'Standard' + + # Check history to avoid reusing song+difficulty + if song_id in history['scoresaber_lowest_pp'] and difficulty in history['scoresaber_lowest_pp'][song_id]: + logging.debug(f"Skipping song {song_id} with difficulty {difficulty} as it's in history.") + continue # Skip if already used + + # Format the song data as expected by PlaylistBuilder + song_dict = { + 'hash': song_id, + 'songName': leaderboard.get('songName', 'Unknown'), + 'difficulties': [ + { + 'name': difficulty, + 'characteristic': game_mode + } + ] + } + + # Add the song to the playlist + playlist_data.append(song_dict) + pp_score = score['score'].get('pp', 0) + logging.info(f"Song added: {song_dict['songName']} ({difficulty}), PP: {pp_score:.2f}") + + # Check if the desired number of songs has been reached + if len(playlist_data) >= song_count: + logging.debug(f"Reached the desired song count: {song_count}.") + break + + # Log if no songs were added + if not playlist_data: + logging.info("No new songs found to add to the playlist based on history.") + else: + logging.info(f"Total songs added to playlist: {len(playlist_data)}") + + # Update history to avoid reusing the same song+difficulty + for song in playlist_data: + song_id = song['hash'] + difficulty_name = song['difficulties'][0]['name'] + history['scoresaber_lowest_pp'].setdefault(song_id, []).append(difficulty_name) + save_history(history) + + return playlist_data, f"scoresaber_lowest_pp-{new_count:02d}" + def map_leaders_by_month(month: int = 9, year: int = 2024, game_modes: List[str] = ['Standard']) -> List[Dict]: """ Gathers a month's worth of maps using the BeatSaver latest maps endpoint, @@ -469,6 +638,48 @@ def playlist_strategy_highest_accuracy( return playlist_data, f"highest_accuracy-{year}-{month:02d}-{new_count:02d}" +def reset_history(strategy: str) -> None: + """ + Reset the history for a given playlist strategy. + + :param strategy: The strategy to reset history for. + """ + history = load_history() + if strategy in history: + del history[strategy] + if 'playlist_counts' in history and strategy in history['playlist_counts']: + history['playlist_counts'][strategy] = 0 + save_history(history) + logging.info(f"History and playlist count for '{strategy}' have been reset.") + else: + logging.info(f"No history found for '{strategy}'. Nothing to reset.") + +def get_strategy(): + parser = argparse.ArgumentParser(description="Generate Beat Saber playlists") + parser.add_argument("-s", "--strategy", + choices=["scoresaber_oldscores", "beatleader_oldscores", "highest_accuracy", "beatleader_lowest_pp", "scoresaber_lowest_pp"], + help="Specify the playlist generation strategy") + parser.add_argument("-r", "--reset", + action="store_true", + help="Reset the history for the specified strategy") + + if len(sys.argv) == 1: + parser.print_help() + sys.exit(1) + + args = parser.parse_args() + + if args.reset: + if not args.strategy: + parser.error("--reset requires --strategy to be specified") + reset_history(args.strategy) + sys.exit(0) + + if not args.strategy: + parser.error("--strategy is required unless --reset is used") + + return args.strategy + def saberlist() -> None: """ Generate a playlist of songs from a range of difficulties, all with scores previously set a long time ago. @@ -483,6 +694,10 @@ def saberlist() -> None: playlist_data, playlist_title = playlist_strategy_beatleader_oldscores(BeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) elif strategy == 'highest_accuracy': playlist_data, playlist_title = playlist_strategy_highest_accuracy() + elif strategy == 'beatleader_lowest_pp': + playlist_data, playlist_title = playlist_strategy_beatleader_lowest_pp(BeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) + elif strategy == 'scoresaber_lowest_pp': + playlist_data, playlist_title = playlist_strategy_scoresaber_lowest_pp(ScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) else: logging.error(f"Unknown strategy '{strategy}'") return @@ -496,17 +711,3 @@ def saberlist() -> None: playlist_title=playlist_title, playlist_author="SaberList Tool" ) - -def get_strategy(): - parser = argparse.ArgumentParser(description="Generate Beat Saber playlists") - parser.add_argument("-s", "--strategy", - choices=["scoresaber_oldscores", "beatleader_oldscores", "highest_accuracy"], - help="Specify the playlist generation strategy", - required=True) - - if len(sys.argv) == 1: - parser.print_help() - sys.exit(1) - - args = parser.parse_args() - return args.strategy