diff --git a/docs/ClientWrapperUsage.md b/docs/ClientWrapperUsage.md index c2b22f0..26bc1c9 100644 --- a/docs/ClientWrapperUsage.md +++ b/docs/ClientWrapperUsage.md @@ -40,7 +40,7 @@ all_beatleader_ranked_maps = beatleader_api.get_player_scores( ```python from helpers.SimpleScoreSaberAPI import SimpleScoreSaberAPI scoresaber_api = SimpleScoreSaberAPI() -scoresaber_all_ranked_maps = scoresaber_api.get_ranked_maps(use_cache=False) +scoresaber_ranked_maps = scoresaber_api.get_ranked_maps() ``` ## BeatSaverClient @@ -57,7 +57,10 @@ map_data = beatsaver_api.get_maps(year=2024, month=9) from helpers.SimpleBeatLeaderAPI import SimpleBeatLeaderAPI player_id = "76561199407393962" beatleader_api = SimpleBeatLeaderAPI() -data = beatleader_api.get_player_accgraph(player_id) + +scores_data = beatleader_api.get_player_scores(player_id).get('data', []) + +acc_graph = beatleader_api.get_player_accgraph(player_id) data[0] filtered_data = [{'acc': item['acc'], 'stars': item['stars'], 'hash': item['hash']} for item in data] filtered_data[0] diff --git a/src/saberlist/make.py b/src/saberlist/make.py index bf52561..a123d9d 100644 --- a/src/saberlist/make.py +++ b/src/saberlist/make.py @@ -22,23 +22,21 @@ from helpers.SimpleScoreSaberAPI import SimpleScoreSaberAPI from helpers.SimpleBeatSaverAPI import SimpleBeatSaverAPI from saberlist.utils import reset_history -from saberlist.playlist_strategies.oldscores import ( +from saberlist.playlist_strategies.beatleader import ( playlist_strategy_beatleader_oldscores, - playlist_strategy_scoresaber_oldscores, + playlist_strategy_beatleader_oldscores_stars, playlist_strategy_ranked_both, ) +from saberlist.playlist_strategies.scoresaber import ( + playlist_strategy_scoresaber_oldscores, + playlist_strategy_scoresaber_ranked, +) from saberlist.playlist_strategies.accuracy import ( - playlist_strategy_beatleader_lowest_acc, playlist_strategy_beatleader_accuracy_gaps, playlist_strategy_scoresaber_accuracy_gaps, playlist_strategy_beatleader_accuracy_gaps_star_range, ) -from saberlist.playlist_strategies.performance import ( - playlist_strategy_beatleader_lowest_pp, - playlist_strategy_scoresaber_lowest_pp, -) from saberlist.playlist_strategies.beatsaver import ( - playlist_strategy_beatsaver_acc, playlist_strategy_beatsaver_curated, playlist_strategy_beatsaver_mappers, ) @@ -63,12 +61,26 @@ def saberlist() -> None: ) playlist_builder = PlaylistBuilder() + elif strategy == 'beatleader_oldscores_stars': + stars = input("Enter star level (Default: 6.0): ") or 6.0 + playlist_data, playlist_title = playlist_strategy_beatleader_oldscores_stars( + SimpleBeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS), + stars=float(stars) + ) + playlist_builder = PlaylistBuilder(covers_dir='./covers/pajamas') + elif strategy == 'beatleader_oldscores': playlist_data, playlist_title = playlist_strategy_beatleader_oldscores( BeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS) ) playlist_builder = PlaylistBuilder() + elif strategy == 'scoresaber_ranked': + playlist_data, playlist_title = playlist_strategy_scoresaber_ranked( + SimpleScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS) + ) + playlist_builder = PlaylistBuilder(covers_dir='./covers/scoresaber') + elif strategy == 'ranked_both': playlist_data, playlist_title = playlist_strategy_ranked_both( SimpleBeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS), @@ -143,6 +155,13 @@ def parse_args_subcommands(): action="store_true", help="Reset the history for scoresaber_oldscores") + # -------- beatleader_oldscores_stars -------- + parser_bl_old_stars = subparsers.add_parser("beatleader_oldscores_stars", + help="Generate a playlist using BeatLeader old-scores-stars strategy") + parser_bl_old_stars.add_argument("-r", "--reset", + action="store_true", + help="Reset the history for beatleader_oldscores_stars") + # -------- beatleader_oldscores -------- parser_bl_old = subparsers.add_parser("beatleader_oldscores", help="Generate a playlist using BeatLeader old-scores strategy") @@ -150,6 +169,13 @@ def parse_args_subcommands(): action="store_true", help="Reset the history for beatleader_oldscores") + # -------- scoresaber_ranked -------- + parser_ss_ranked = subparsers.add_parser("scoresaber_ranked", + help="Generate a playlist using scoresaber_ranked strategy") + parser_ss_ranked.add_argument("-r", "--reset", + action="store_true", + help="Reset the history for scoresaber_ranked") + # -------- ranked_both -------- parser_ranked_both = subparsers.add_parser("ranked_both", help="Generate a playlist using ranked_both strategy") diff --git a/src/saberlist/playlist_strategies/oldscores.py b/src/saberlist/playlist_strategies/beatleader.py similarity index 69% rename from src/saberlist/playlist_strategies/oldscores.py rename to src/saberlist/playlist_strategies/beatleader.py index eabe098..1017ad9 100644 --- a/src/saberlist/playlist_strategies/oldscores.py +++ b/src/saberlist/playlist_strategies/beatleader.py @@ -13,7 +13,6 @@ logging.basicConfig( level=LOG_LEVEL ) -from helpers.ScoreSaberAPI import ScoreSaberAPI from helpers.BeatLeaderAPI import BeatLeaderAPI from helpers.SimpleBeatLeaderAPI import SimpleBeatLeaderAPI from helpers.SimpleScoreSaberAPI import SimpleScoreSaberAPI @@ -105,6 +104,127 @@ def playlist_strategy_ranked_both( return playlist_data, f"ranked_both-{new_count:02d}" +def playlist_strategy_beatleader_oldscores_stars( + beatleader_api: SimpleBeatLeaderAPI, + song_count: int = 40, + stars: float = 6.0 +) -> List[Dict[str, Any]]: + """ + Build and format a list of songs from BeatLeader that have a star rating + between 'stars' (default: 6) and stars+1 (exclusive). Avoid reusing the same + song+difficulty from history. + + Args: + beatleader_api (SimpleBeatLeaderAPI): API interface to fetch player scores. + song_count (int, optional): Number of songs to return in the playlist. Defaults to 20. + stars (float, optional): Starting range of star rating (x). Only songs with + x <= star < x+1 are included. Defaults to 6.0. + + Returns: + List[Dict[str, Any]]: A playlist list of dictionary items suitable for building a playlist + (along with a formatted playlist identifier). + """ + player_id = prompt_for_player_id() + history = load_history() + history.setdefault('beatleader_oldscores_stars', {}) + history.setdefault('playlist_counts', {}) + + # Get the current count for the star-based strategy and increment it + count_key = 'beatleader_oldscores_stars' + current_count = history['playlist_counts'].get(count_key, 0) + new_count = current_count + 1 + history['playlist_counts'][count_key] = new_count + + # Fetch player scores + scores_data = beatleader_api.get_player_scores(player_id).get('data', []) + if not scores_data: + logging.warning(f"No scores found for player ID {player_id} on BeatLeader.") + return [], "" + + logging.debug(f"Found {len(scores_data)} total scores for player ID {player_id} on BeatLeader.") + + # Sort scores by epochTime in ascending order (if you want "oldest" style, similar to oldscores). + # Remove this sort if you prefer a different ordering. + scores_data.sort(key=lambda x: x.get('timepost', 0)) + + playlist_data = [] + current_time = datetime.now(timezone.utc) + + for score_entry in scores_data: + if len(playlist_data) >= song_count: + break # Stop if we've reached the desired number of songs + + leaderboard = score_entry.get('leaderboard', {}) + difficulty_data = leaderboard.get('difficulty', {}) + star_rating = difficulty_data.get('stars') + song_hash = leaderboard.get('song', {}).get('hash') + difficulty_raw = difficulty_data.get('value') + game_mode = difficulty_data.get('modeName', 'Standard') + epoch_time = score_entry.get('timepost') + + # Skip if we have missing data + if not song_hash or not difficulty_raw or star_rating is None: + continue + + # Filter by star rating range [stars, stars + 1) + if not (stars <= star_rating < stars + 1): + continue + + # Normalize difficulty name + difficulty = normalize_difficulty_name(difficulty_raw) + + # Avoid reusing song+difficulty + if (song_hash in history['beatleader_oldscores_stars'] and + difficulty in history['beatleader_oldscores_stars'][song_hash]): + logging.debug(f"Skipping song {song_hash} (difficulty {difficulty}) as it's in history.") + continue + + # Calculate how long ago the score was set + try: + time_set = datetime.fromtimestamp(epoch_time, tz=timezone.utc) + except (ValueError, OSError) as e: + logging.error(f"Invalid epochTime for this score: {e}") + continue + time_difference = current_time - time_set + time_ago = format_time_ago(time_difference) + + # Format the song data for PlaylistBuilder + song_dict = { + 'hash': song_hash, + 'difficulties': [ + { + 'name': difficulty, + 'characteristic': game_mode + } + ] + } + + # Add to playlist + playlist_data.append(song_dict) + logging.debug( + f"Selected song for playlist: Hash={song_hash}, Difficulty={difficulty}, " + f"Stars={star_rating:.2f}. Last played {time_ago} ago." + ) + + # Update history + history['beatleader_oldscores_stars'].setdefault(song_hash, []).append(difficulty) + + if len(playlist_data) >= song_count: + break + + if not playlist_data: + logging.info("No new songs found to add to the playlist based on history for BeatLeader (star-based).") + else: + for song in playlist_data: + song_hash = song['hash'] + diff_name = song['difficulties'][0]['name'] + logging.info(f"Song added: Hash={song_hash}, Difficulty={diff_name}.") + logging.info(f"Total songs added to playlist from BeatLeader (star-based): {len(playlist_data)}") + + save_history(history) + + return playlist_data, f"beatleader_oldscores_stars-{new_count:02d}" + def playlist_strategy_beatleader_oldscores( api: BeatLeaderAPI, song_count: int = 20 @@ -196,106 +316,3 @@ def playlist_strategy_beatleader_oldscores( save_history(history) return playlist_data, f"beatleader_oldscores-{new_count:02d}" - -def playlist_strategy_scoresaber_oldscores( - api: ScoreSaberAPI, - song_count: int = 20 -) -> List[Dict[str, Any]]: - """Build and format a list of songs based on old scores from ScoreSaber, avoiding reusing the same song+difficulty.""" - - player_id = prompt_for_player_id() - history = load_history() - history.setdefault('scoresaber_oldscores', {}) - history.setdefault('playlist_counts', {}) - - # Get the current count for ScoreSaber old scores and increment it - count_key = 'scoresaber_oldscores' - 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}.") - - # Sort scores by timeSet in ascending order (oldest first) - all_scores.sort(key=lambda x: x['score'].get('timeSet', '')) - - playlist_data = [] - current_time = datetime.now(timezone.utc) - - for score in all_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 - - # Calculate time ago - time_set_str = score['score'].get('timeSet') - if not time_set_str: - logging.debug(f"Skipping score due to missing timeSet: {score}") - continue # Skip if time_set is missing - try: - time_set = datetime.fromisoformat(time_set_str.replace('Z', '+00:00')) - except ValueError as e: - logging.error(f"Invalid time format for score ID {score['score'].get('id')}: {e}") - continue - time_difference = current_time - time_set - time_ago = format_time_ago(time_difference) - - # 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_oldscores'] and difficulty in history['scoresaber_oldscores'][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) - logging.debug(f"Selected song for playlist: {song_dict['songName']} ({difficulty})") - - # Log the song addition - mapper = "Unknown" # Mapper information can be added if available - logging.info(f"Song added: {song_dict['songName']} ({difficulty}), mapped by {mapper}. Last played {time_ago} ago.") - - # 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_oldscores'].setdefault(song_id, []).append(difficulty_name) - save_history(history) - - return playlist_data, f"scoresaber_oldscores-{new_count:02d}" \ No newline at end of file diff --git a/src/saberlist/playlist_strategies/scoresaber.py b/src/saberlist/playlist_strategies/scoresaber.py new file mode 100644 index 0000000..14fc5c3 --- /dev/null +++ b/src/saberlist/playlist_strategies/scoresaber.py @@ -0,0 +1,214 @@ +from typing import Dict, Any, List, Tuple +from datetime import datetime, timezone +import logging +import os +from dotenv import load_dotenv +load_dotenv() +LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper() + +import logging +logging.basicConfig( + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=LOG_LEVEL +) + +from helpers.ScoreSaberAPI import ScoreSaberAPI +from helpers.SimpleScoreSaberAPI import SimpleScoreSaberAPI + +from saberlist.utils import load_history, save_history, normalize_difficulty_name, format_time_ago, prompt_for_player_id + +def playlist_strategy_scoresaber_ranked( + scoresaber_api: SimpleScoreSaberAPI, + min_star: float = 7.0, + max_star: float = 10.0, + song_count: int = 40 +) -> Tuple[List[Dict[str, Any]], str]: + """ + Build and format a list of the latest ranked songs from ScoreSaber within the given star range, + avoiding reusing the same song+difficulty. + Returns: + (playlist_data, playlist_identifier): + playlist_data: A list of playlist songs. + playlist_identifier: A formatted string identifying this playlist version. + """ + history = load_history() + # Set up the history keys if they don't exist + history.setdefault('scoresaber_ranked', {}) + history.setdefault('playlist_counts', {}) + + # Prepare a unique key for this strategy and update its usage count + count_key = 'scoresaber_ranked' + current_count = history['playlist_counts'].get(count_key, 0) + new_count = current_count + 1 + history['playlist_counts'][count_key] = new_count + + # Fetch ScoreSaber ranked maps within the desired star range + logging.debug(f"Fetching ScoreSaber ranked maps with star range [{min_star}, {max_star}]...") + ranked_maps = scoresaber_api.get_ranked_maps( + min_star=min_star, + max_star=max_star + ) + + # Filter maps that are actually ranked, and sort by rankedDate descending (latest first) + filtered_maps = [ + m for m in ranked_maps + if m.get('ranked') is True and m.get('rankedDate') is not None + ] + filtered_maps.sort(key=lambda x: x['rankedDate'], reverse=True) + logging.info(f"Retrieved {len(filtered_maps)} ranked maps from ScoreSaber in the star range.") + + playlist_data = [] + + for map_data in filtered_maps: + if len(playlist_data) >= song_count: + logging.debug(f"Reached the desired song count: {song_count}.") + break + + song_hash = map_data.get('songHash') + difficulty_info = map_data.get('difficulty', {}) + difficulty_raw = difficulty_info.get('difficultyRaw') + game_mode = difficulty_info.get('gameMode', 'Standard') + + if not song_hash or not difficulty_raw: + logging.debug(f"Skipping map due to missing hash or difficulty info: {map_data}") + continue + + # Normalize the difficulty name (reusing the helper from oldscores) + difficulty = normalize_difficulty_name(difficulty_raw) + + # Avoid reusing the same song + difficulty + if song_hash in history['scoresaber_ranked'] and difficulty in history['scoresaber_ranked'][song_hash]: + logging.debug(f"Skipping song {song_hash} - {difficulty} as it's in history.") + continue + + # Prepare the data for the playlist + song_dict = { + 'hash': song_hash, + 'songName': map_data.get('songName', 'Unknown'), + 'difficulties': [ + { + 'name': difficulty, + 'characteristic': 'Standard' if 'Standard' in game_mode else game_mode + } + ] + } + playlist_data.append(song_dict) + logging.debug(f"Added {song_dict['songName']} (Difficulty Raw: {difficulty_raw}) to the playlist.") + + # Update history to mark this song + difficulty combination as used + history['scoresaber_ranked'].setdefault(song_hash, []).append(difficulty) + + # Log final results + if playlist_data: + logging.info(f"Added {len(playlist_data)} new songs to the playlist.") + else: + logging.info("No new ranked songs were added to the playlist based on history.") + + save_history(history) + + return playlist_data, f"scoresaber_ranked-{new_count:02d}" + + +def playlist_strategy_scoresaber_oldscores( + api: ScoreSaberAPI, + song_count: int = 20 +) -> List[Dict[str, Any]]: + """Build and format a list of songs based on old scores from ScoreSaber, avoiding reusing the same song+difficulty.""" + + player_id = prompt_for_player_id() + history = load_history() + history.setdefault('scoresaber_oldscores', {}) + history.setdefault('playlist_counts', {}) + + # Get the current count for ScoreSaber old scores and increment it + count_key = 'scoresaber_oldscores' + 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}.") + + # Sort scores by timeSet in ascending order (oldest first) + all_scores.sort(key=lambda x: x['score'].get('timeSet', '')) + + playlist_data = [] + current_time = datetime.now(timezone.utc) + + for score in all_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 + + # Calculate time ago + time_set_str = score['score'].get('timeSet') + if not time_set_str: + logging.debug(f"Skipping score due to missing timeSet: {score}") + continue # Skip if time_set is missing + try: + time_set = datetime.fromisoformat(time_set_str.replace('Z', '+00:00')) + except ValueError as e: + logging.error(f"Invalid time format for score ID {score['score'].get('id')}: {e}") + continue + time_difference = current_time - time_set + time_ago = format_time_ago(time_difference) + + # 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_oldscores'] and difficulty in history['scoresaber_oldscores'][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) + logging.debug(f"Selected song for playlist: {song_dict['songName']} ({difficulty})") + + # Log the song addition + mapper = "Unknown" # Mapper information can be added if available + logging.info(f"Song added: {song_dict['songName']} ({difficulty}), mapped by {mapper}. Last played {time_ago} ago.") + + # 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_oldscores'].setdefault(song_id, []).append(difficulty_name) + save_history(history) + + return playlist_data, f"scoresaber_oldscores-{new_count:02d}" \ No newline at end of file