Add strategy to pick ranked scores of a given star level that haven't been played in awhile

This commit is contained in:
Brian Lee 2025-01-30 07:51:51 -08:00
parent 3b1d66cb30
commit faf42af274
4 changed files with 374 additions and 114 deletions

View File

@ -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]

View File

@ -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")

View File

@ -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}"

View File

@ -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}"