diff --git a/src/saberlist/make.py b/src/saberlist/make.py index 6faf091..4e72ff5 100644 --- a/src/saberlist/make.py +++ b/src/saberlist/make.py @@ -26,6 +26,8 @@ from helpers.PlaylistBuilder import PlaylistBuilder from helpers.ScoreSaberAPI import ScoreSaberAPI from helpers.BeatLeaderAPI import BeatLeaderAPI +import calendar + def load_history() -> Dict[str, Any]: """ Load the playlist history from a JSON file. @@ -34,8 +36,10 @@ def load_history() -> Dict[str, Any]: """ if os.path.exists(HISTORY_FILE): with open(HISTORY_FILE, 'r') as f: - return json.load(f) - return {} + history = json.load(f) + history.setdefault('playlist_counts', {}) + return history + return {'highest_accuracy': {}, 'playlist_counts': {}} def save_history(history: Dict[str, Any]) -> None: """ @@ -298,101 +302,165 @@ def playlist_strategy_beatleader_oldscores( return playlist_data -def map_leaders_by_month(month: int = 9, year: int = 2024) -> List[Dict]: +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, prioritizes map difficulties where players have already set good scores, and calculates the average accuracy for each map+difficulty. + Args: + month: The month to gather maps for. + year: The year to gather maps for. + game_modes: The game modes to include. + Returns: A list of dictionaries, each containing: + - hash: Hash of the map + - difficulties: List of difficulties with their characteristics - map_name: Name of the map - - difficulty: Difficulty level - average_accuracy: Average accuracy of the leaderboard """ - beatleader_api = SimpleBeatLeaderAPI() - beatsaver_api = BeatSaverAPI() + beatleader_api = SimpleBeatLeaderAPI(cache_expiry_days=30) + beatsaver_api = BeatSaverAPI(cache_expiry_days=30) - map_data = beatsaver_api.get_maps(year=year, month=month) + logging.debug(f"Fetching maps for {month}/{year}") + map_data = beatsaver_api.get_maps(year=year, month=month, page_size=100) collected_data = [] - for map_entry in map_data: + for i, map_entry in enumerate(map_data): + # Ensure there are versions available if not map_entry.versions: logging.warning(f"No versions found for map: {map_entry.name}") continue latest_version = max(map_entry.versions, key=lambda version: version.created_at) - # latest_version_hash = latest_version.hash_ + song_hash = latest_version.hash_ for diff in latest_version.diffs: - if diff.characteristic != 'Standard': + if diff.characteristic not in game_modes: continue - leaderboard_data = beatleader_api.get_leaderboard(latest_version.hash_, diff.difficulty) + logging.info(f"Getting leaderboard for {i+1}/{len(map_data)}: {map_entry.name}") + leaderboard_data = beatleader_api.get_leaderboard(song_hash, diff.difficulty) if not leaderboard_data: - logging.warning(f"No leaderboard data for {map_entry.name} [{diff.difficulty}]") + logging.debug(f"No leaderboard data for {map_entry.name} [{diff.difficulty}], skipping") continue # Calculate average accuracy accuracies = [entry.get('accuracy', 0) for entry in leaderboard_data if 'accuracy' in entry] if not accuracies: - logging.warning(f"No accuracy data for {map_entry.name} [{diff.difficulty}]") + logging.debug(f"No accuracy data for {map_entry.name} [{diff.difficulty}]") continue avg_accuracy = mean(accuracies) collected_data.append({ + 'hash': song_hash, + 'difficulties': [ + { + 'name': diff.difficulty, + 'characteristic': diff.characteristic + } + ], 'map_name': map_entry.name, - 'difficulty': diff.difficulty, 'average_accuracy': avg_accuracy }) - logging.info(f"Collected {len(collected_data)} map+difficulty combinations by average accuracy for {month}/{year}.") + logging.info(f"Collected leaderboards for {len(collected_data)} map+difficulty combinations, orderable by average accuracy of top ten plays for {month}/{year}.") return collected_data -def playlist_strategy_highest_accuracy(count: int = 40) -> List[Dict]: +def playlist_strategy_highest_accuracy( + song_count: int = 40 +) -> List[Dict[str, Any]]: """ - Selects the top map+difficulty combinations with the highest average accuracy. - - Args: - count: The number of map+difficulty combinations to select. Default is 40. - - Returns: - A list of dictionaries containing the selected map+difficulty combinations, - each with: - - map_name: Name of the map - - difficulty: Difficulty level - - average_accuracy: Average accuracy of the leaderboard + Build and format a list of songs based on the highest average accuracy from recent maps. + Prompts the user for the month and year, defaulting to last month. + Excludes any map that's in the history, regardless of difficulty. + + :param song_count: The number of songs to include in the playlist. Default is 40. + :return: A list of dictionaries containing song information for the playlist. """ - # Retrieve the collected map+difficulty data with average accuracies - map_difficulty_data = map_leaders_by_month() + history = load_history() + history.setdefault('highest_accuracy', {}) + history.setdefault('playlist_counts', {}) - if not map_difficulty_data: - logging.error("No map+difficulty data available to create a playlist.") + # Get last month's date + today = datetime.now() + last_month = today.replace(day=1) - timedelta(days=1) + default_month = last_month.month + default_year = last_month.year + + # Prompt for month and year + while True: + month_input = input(f"Enter month (1-12, default {default_month}): ").strip() or str(default_month) + year_input = input(f"Enter year (default {default_year}): ").strip() or str(default_year) + + try: + month = int(month_input) + year = int(year_input) + if 1 <= month <= 12 and 2000 <= year <= datetime.now().year: + break + else: + print("Invalid month or year. Please try again.") + except ValueError: + print("Invalid input. Please enter numbers only.") + + # Get the current count for this year/month and increment it + count_key = f"{year}-{month:02d}" + current_count = history['playlist_counts'].get(count_key, 0) + new_count = current_count + 1 + history['playlist_counts'][count_key] = new_count + + leaderboard_data = map_leaders_by_month(month=month, year=year) + + if not leaderboard_data: + logging.error(f"No map+difficulty data available for {calendar.month_name[month]} {year}.") return [] # Sort the data by average_accuracy in descending order sorted_data = sorted( - map_difficulty_data, + leaderboard_data, key=lambda x: x['average_accuracy'], reverse=True ) - # Select the top 'count' entries - selected_playlist = sorted_data[:count] + playlist_data = [] + for entry in sorted_data: + if len(playlist_data) >= song_count: + break - # Log the selected playlist - logging.info(f"Selected top {count} map+difficulty combinations by average accuracy:") - for idx, entry in enumerate(selected_playlist, start=1): - logging.info( - f"{idx}. {entry['map_name']} [{entry['difficulty']}] - " - f"Average Accuracy: {entry['average_accuracy'] * 100:.2f}%" - ) + song_hash = entry['hash'] - return selected_playlist + # Check history to avoid reusing any map, regardless of difficulty + if song_hash in history['highest_accuracy']: + logging.debug(f"Skipping song {song_hash} as it's in history.") + continue + + playlist_data.append({ + 'hash': song_hash, + 'difficulties': entry['difficulties'], + 'songName': entry['map_name'] + }) + + # Log the song addition + difficulty = entry['difficulties'][0]['name'] + logging.info(f"Song added: {entry['map_name']} ({difficulty}) - Average Accuracy: {entry['average_accuracy'] * 100:.2f}%") + + # Update history (now we're just adding the song hash, not the difficulty) + history['highest_accuracy'][song_hash] = True + + # Log if no songs were added + if not playlist_data: + logging.info(f"No new songs found to add to the playlist for {calendar.month_name[month]} {year} based on history.") + else: + logging.info(f"Total songs added to playlist: {len(playlist_data)}") + + save_history(history) + + return playlist_data, f"highest_accuracy-{year}-{month:02d}-{new_count:02d}" def saberlist() -> None: """ @@ -401,36 +469,34 @@ def saberlist() -> None: Avoids reusing the same song+difficulty in a playlist based on history. """ strategy = get_strategy() + + timestamp = datetime.now().strftime("%y%m%d_%H%M%S") + playlist_title = f"{strategy}-{timestamp}" + if strategy == 'scoresaber_oldscores': - api = ScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS) + playlist_data = playlist_strategy_scoresaber_oldscores(ScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) elif strategy == 'beatleader_oldscores': - api = BeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS) + playlist_data = playlist_strategy_beatleader_oldscores(BeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS)) + elif strategy == 'highest_accuracy': + playlist_data, playlist_title = playlist_strategy_highest_accuracy() else: logging.error(f"Unknown strategy '{strategy}'") return - timestamp = datetime.now().strftime("%y%m%d_%H%M") - playlist_name = f"{strategy}-{timestamp}" - - if strategy == 'scoresaber_oldscores': - playlist_data = playlist_strategy_scoresaber_oldscores(api) - elif strategy == 'beatleader_oldscores': - playlist_data = playlist_strategy_beatleader_oldscores(api) - if not playlist_data: logging.info("No new scores found to add to the playlist.") return PlaylistBuilder().create_playlist( playlist_data, - playlist_title=playlist_name, + 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"], + choices=["scoresaber_oldscores", "beatleader_oldscores", "highest_accuracy"], help="Specify the playlist generation strategy", required=True) @@ -439,4 +505,4 @@ def get_strategy(): sys.exit(1) args = parser.parse_args() - return args.strategy \ No newline at end of file + return args.strategy