diff --git a/README.md b/README.md index 651a4fa..c42217e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ SaberList is a tool for generating custom Beat Saber playlists based on a player's performance data from Beat Leader. +## Resources + +* Beat Leader [swagger](https://api.beatleader.xyz/swagger/index.html), [GitHub](https://github.com/BeatLeader) +* Score Saber [swagger](https://docs.scoresaber.com/), [Github](https://github.com/ScoreSaber) (backend remains closed-source) +* Beat Saver [swagger](https://api.beatsaver.com/docs/), [GitHub](https://github.com/beatmaps-io/beatsaver-main) + ## Features - Fetches player scores from Beat Leader API diff --git a/docs/ClientWrapperUsage.md b/docs/ClientWrapperUsage.md new file mode 100644 index 0000000..0e26ecc --- /dev/null +++ b/docs/ClientWrapperUsage.md @@ -0,0 +1,26 @@ +# API Client Wrapper Usage + +## BeatLeaderClient + +```python +from helpers.BeatLeaderAPI import BeatLeaderAPI +from clients.beatleader.models.scores_sort_by import ScoresSortBy +from clients.beatleader.models.order import Order + +# Instantiate the API client +beatleader_api = BeatLeaderAPI() + +# Specify the player ID you want to fetch scores for +player_id = "76561199407393962" + +# Fetch player scores +scores_data = beatleader_api.get_player_scores( + player_id=player_id, + use_cache=True, # Use cached data if available + count=100, # Number of scores per page + sort_by=ScoresSortBy.DATE, # Sort scores by date + order=Order.DESC, # In descending order + max_pages=2 # Maximum number of pages to fetch +) +print(f"Got {len(scores_data.get('playerScores'))} scores for player {player_id}") +``` diff --git a/docs/Python.md b/docs/Python.md new file mode 100644 index 0000000..2ef3bd1 --- /dev/null +++ b/docs/Python.md @@ -0,0 +1,11 @@ +# Python + +Paginate a large dictionary: + +```python +from IPython.core.page import page +from pprint import pformat +def paged_print(obj): + page(pformat(obj)) +paged_print(scores) +``` diff --git a/docs/SimpleBeatLeaderAPI.md b/docs/SimpleBeatLeaderAPI.md new file mode 100644 index 0000000..3e0485f --- /dev/null +++ b/docs/SimpleBeatLeaderAPI.md @@ -0,0 +1,78 @@ +# SimpleBeatLeaderAPI Python Wrapper + +This simple Python class provides a convenient wrapper for interacting with the BeatLeader API, specifically for retrieving player scores and song data for the game Beat Saber. + +## Features + +- Fetch player scores and song data from the BeatLeader API +- Local caching of API responses to reduce API calls and improve performance +- Automatic pagination handling to retrieve all available data +- Configurable cache expiration +- Methods to retrieve both full and minimal song data + +## Usage + +### Basic Usage + +```python +from saberlist.SimpleBeatLeaderAPI import BeatLeaderAPI + +# Initialize the API wrapper +api = SimpleBeatLeaderAPI(cache_expiry_days=1) + +# Fetch player scores +player_id = '76561199407393962' +scores = api.get_player_scores(player_id) +scores.keys() +scores['data'][0] + +# Get full song data +songs = api.get_player_songs(player_id) + +``` + +### Caching + +The class uses a local cache to store API responses. By default, the cache is located at: + +- `~/.cache/saberlist/` on Unix-like systems (if `~/.cache/` exists) +- `./.cache/` in the current working directory (as a fallback) + +You can control cache behavior: + +```python +# Set cache expiry (in days) +api = SimpleBeatLeaderAPI(cache_expiry_days=7) + +# Force a fresh API call (ignore cache) +fresh_scores = api.get_player_scores(player_id, use_cache=False) + +# Clear cache for a specific player +api.clear_cache(player_id) + +# Clear all cache +api.clear_cache() + +# Get the current cache directory +cache_dir = api.get_cache_dir() +``` + +### Pagination + +The `get_player_scores` method automatically handles pagination to retrieve all available scores. You can control this behavior: + +```python +# Set a custom page size (default is 100) +scores = api.get_player_scores(player_id, page_size=50) + +# Limit the number of pages fetched +scores = api.get_player_scores(player_id, max_pages=5) +``` + +## Methods + +- `get_player_scores(player_id, use_cache=True, page_size=100, max_pages=None)`: Retrieves all scores for a player. +- `get_player_songs(player_id, page=1, count=100, use_cache=True)`: Retrieves full song data for all unique songs a player has played. +- `get_player_songs_minimal(player_id, page=1, count=100, use_cache=True)`: Retrieves minimal song data (id, name, author, mapper, hash, bpm, duration) for all unique songs a player has played. +- `clear_cache(player_id=None)`: Clears the cache for a specific player or all cached data. +- `get_cache_dir()`: Returns the path to the current cache directory. diff --git a/docs/prompts/01-new.md b/docs/prompts/01-new.md deleted file mode 100644 index 06af731..0000000 --- a/docs/prompts/01-new.md +++ /dev/null @@ -1,392 +0,0 @@ -# Python coding - -We used openapi-python-client to generate client libraries for the scoresaber api. It's in clients/scoresaber in our python project: - -``` -treegit -. -├── docs -│   ├── api.md -│   ├── capture.md -│   └── PlaylistBuilder.md -├── src -│   ├── clients -│   │   ├── beatleader -│   │   │   ├── api -│   │   │   │   ├── beast_saber -│   │   │   │   │   ├── beast_saber_get_all.py -│   │   │   │   │   ├── beast_saber_nominate.py -│   │   │   │   │   └── __init__.py -│   │   │   │   ├── clan -│   │   │   │   │   ├── clan_get_all.py -│   │   │   │   │   ├── clan_get_clan_by_id.py -│   │   │   │   │   ├── clan_get_clan.py -│   │   │   │   │   ├── clan_get_clan_with_maps_by_id.py -│   │   │   │   │   ├── clan_get_clan_with_maps.py -│   │   │   │   │   ├── clan_get_history.py -│   │   │   │   │   ├── clan_global_map.py -│   │   │   │   │   └── __init__.py -│   │   │   │   ├── leaderboard -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   ├── leaderboard_get_all.py -│   │   │   │   │   ├── leaderboard_get_clan_rankings.py -│   │   │   │   │   ├── leaderboard_get.py -│   │   │   │   │   └── leaderboard_get_scoregraph.py -│   │   │   │   ├── modifiers -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   └── modifiers_get_modifiers.py -│   │   │   │   ├── patreon -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   └── patreon_refresh_my_patreon.py -│   │   │   │   ├── player -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   ├── player_get_beat_saver.py -│   │   │   │   │   ├── player_get_discord.py -│   │   │   │   │   ├── player_get_followers_info.py -│   │   │   │   │   ├── player_get_followers.py -│   │   │   │   │   ├── player_get_founded_clan.py -│   │   │   │   │   ├── player_get_participating_events.py -│   │   │   │   │   ├── player_get_patreon.py -│   │   │   │   │   ├── player_get_players.py -│   │   │   │   │   ├── player_get.py -│   │   │   │   │   └── player_get_ranked_maps.py -│   │   │   │   ├── player_scores -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   ├── player_scores_acc_graph.py -│   │   │   │   │   ├── player_scores_get_compact_history.py -│   │   │   │   │   ├── player_scores_get_compact_scores.py -│   │   │   │   │   ├── player_scores_get_history.py -│   │   │   │   │   ├── player_scores_get_pinned_scores.py -│   │   │   │   │   ├── player_scores_get_scores.py -│   │   │   │   │   └── player_scores_get_score_value.py -│   │   │   │   ├── song -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   └── song_get_all.py -│   │   │   │   └── __init__.py -│   │   │   ├── models -│   │   │   │   ├── __pycache__ -│   │   │   │   ├── achievement_description.py -│   │   │   │   ├── achievement_level.py -│   │   │   │   ├── achievement.py -│   │   │   │   ├── badge.py -│   │   │   │   ├── ban.py -│   │   │   │   ├── beasties_nomination.py -│   │   │   │   ├── besties_nomination_response.py -│   │   │   │   ├── clan_bigger_response.py -│   │   │   │   ├── clan_global_map_point.py -│   │   │   │   ├── clan_global_map.py -│   │   │   │   ├── clan_map_connection.py -│   │   │   │   ├── clan_maps_sort_by.py -│   │   │   │   ├── clan_point.py -│   │   │   │   ├── clan.py -│   │   │   │   ├── clan_ranking_response_clan_response_full_response_with_metadata_and_container.py -│   │   │   │   ├── clan_ranking_response.py -│   │   │   │   ├── clan_response_full.py -│   │   │   │   ├── clan_response_full_response_with_metadata.py -│   │   │   │   ├── clan_response.py -│   │   │   │   ├── clan_sort_by.py -│   │   │   │   ├── compact_leaderboard.py -│   │   │   │   ├── compact_leaderboard_response.py -│   │   │   │   ├── compact_score.py -│   │   │   │   ├── compact_score_response.py -│   │   │   │   ├── compact_score_response_response_with_metadata.py -│   │   │   │   ├── compact_song_response.py -│   │   │   │   ├── controller_enum.py -│   │   │   │   ├── criteria_commentary.py -│   │   │   │   ├── difficulty_description.py -│   │   │   │   ├── difficulty_response.py -│   │   │   │   ├── difficulty_status.py -│   │   │   │   ├── event_player.py -│   │   │   │   ├── event_ranking.py -│   │   │   │   ├── external_status.py -│   │   │   │   ├── featured_playlist.py -│   │   │   │   ├── featured_playlist_response.py -│   │   │   │   ├── follower_type.py -│   │   │   │   ├── global_map_history.py -│   │   │   │   ├── history_compact_response.py -│   │   │   │   ├── hmd.py -│   │   │   │   ├── info_to_highlight.py -│   │   │   │   ├── __init__.py -│   │   │   │   ├── leaderboard_change.py -│   │   │   │   ├── leaderboard_clan_ranking_response.py -│   │   │   │   ├── leaderboard_contexts.py -│   │   │   │   ├── leaderboard_group_entry.py -│   │   │   │   ├── leaderboard_info_response.py -│   │   │   │   ├── leaderboard_info_response_response_with_metadata.py -│   │   │   │   ├── leaderboard.py -│   │   │   │   ├── leaderboard_response.py -│   │   │   │   ├── leaderboard_sort_by.py -│   │   │   │   ├── legacy_modifiers.py -│   │   │   │   ├── link_response.py -│   │   │   │   ├── map_diff_response.py -│   │   │   │   ├── map_info_response.py -│   │   │   │   ├── map_info_response_response_with_metadata.py -│   │   │   │   ├── mapper.py -│   │   │   │   ├── mapper_response.py -│   │   │   │   ├── map_quality.py -│   │   │   │   ├── map_sort_by.py -│   │   │   │   ├── maps_type.py -│   │   │   │   ├── metadata.py -│   │   │   │   ├── modifiers_map.py -│   │   │   │   ├── modifiers_rating.py -│   │   │   │   ├── my_type.py -│   │   │   │   ├── operation.py -│   │   │   │   ├── order.py -│   │   │   │   ├── participating_event_response.py -│   │   │   │   ├── patreon_features.py -│   │   │   │   ├── player_change.py -│   │   │   │   ├── player_context_extension.py -│   │   │   │   ├── player_follower.py -│   │   │   │   ├── player_followers_info_response.py -│   │   │   │   ├── player.py -│   │   │   │   ├── player_response_clan_response_full_response_with_metadata_and_container.py -│   │   │   │   ├── player_response_full.py -│   │   │   │   ├── player_response.py -│   │   │   │   ├── player_response_with_stats.py -│   │   │   │   ├── player_response_with_stats_response_with_metadata.py -│   │   │   │   ├── player_score_stats_history.py -│   │   │   │   ├── player_score_stats.py -│   │   │   │   ├── player_search.py -│   │   │   │   ├── player_social.py -│   │   │   │   ├── player_sort_by.py -│   │   │   │   ├── pp_type.py -│   │   │   │   ├── profile_settings.py -│   │   │   │   ├── qualification_change.py -│   │   │   │   ├── qualification_commentary.py -│   │   │   │   ├── qualification_vote.py -│   │   │   │   ├── ranked_mapper_response.py -│   │   │   │   ├── ranked_map.py -│   │   │   │   ├── rank_qualification.py -│   │   │   │   ├── rank_update_change.py -│   │   │   │   ├── rank_update.py -│   │   │   │   ├── rank_voting.py -│   │   │   │   ├── replay_offsets.py -│   │   │   │   ├── requirements.py -│   │   │   │   ├── score_filter_status.py -│   │   │   │   ├── score_graph_entry.py -│   │   │   │   ├── score_improvement.py -│   │   │   │   ├── score_metadata.py -│   │   │   │   ├── score_response.py -│   │   │   │   ├── score_response_with_acc.py -│   │   │   │   ├── score_response_with_my_score.py -│   │   │   │   ├── score_response_with_my_score_response_with_metadata.py -│   │   │   │   ├── scores_sort_by.py -│   │   │   │   ├── song.py -│   │   │   │   ├── song_response.py -│   │   │   │   ├── song_status.py -│   │   │   │   ├── type.py -│   │   │   │   └── voter_feedback.py -│   │   │   ├── __pycache__ -│   │   │   ├── client.py -│   │   │   ├── errors.py -│   │   │   ├── __init__.py -│   │   │   ├── py.typed -│   │   │   └── types.py -│   │   ├── beatsaver -│   │   │   ├── api -│   │   │   │   ├── maps -│   │   │   │   │   ├── get_maps_collaborations_id.py -│   │   │   │   │   ├── get_maps_hash_hash.py -│   │   │   │   │   ├── get_maps_id_id.py -│   │   │   │   │   ├── get_maps_ids_ids.py -│   │   │   │   │   ├── get_maps_latest.py -│   │   │   │   │   ├── get_maps_plays_page.py -│   │   │   │   │   ├── get_maps_uploader_id_page.py -│   │   │   │   │   └── __init__.py -│   │   │   │   ├── playlists -│   │   │   │   │   ├── get_playlists_id_id_page.py -│   │   │   │   │   ├── get_playlists_latest.py -│   │   │   │   │   ├── get_playlists_search_page.py -│   │   │   │   │   ├── get_playlists_user_user_id_page.py -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   └── post_playlists_id_id_batch.py -│   │   │   │   ├── search -│   │   │   │   │   ├── get_search_text_page.py -│   │   │   │   │   └── __init__.py -│   │   │   │   ├── users -│   │   │   │   │   ├── get_users_id_id.py -│   │   │   │   │   ├── get_users_ids_ids.py -│   │   │   │   │   ├── get_users_name_name.py -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   └── post_users_verify.py -│   │   │   │   ├── vote -│   │   │   │   │   ├── get_vote.py -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   └── post_vote.py -│   │   │   │   └── __init__.py -│   │   │   ├── models -│   │   │   │   ├── __pycache__ -│   │   │   │   ├── action_response.py -│   │   │   │   ├── auth_request.py -│   │   │   │   ├── get_maps_latest_sort.py -│   │   │   │   ├── get_playlists_latest_sort.py -│   │   │   │   ├── get_playlists_search_page_sort_order.py -│   │   │   │   ├── get_search_text_page_leaderboard.py -│   │   │   │   ├── get_search_text_page_sort_order.py -│   │   │   │   ├── __init__.py -│   │   │   │   ├── map_detail_declared_ai.py -│   │   │   │   ├── map_detail_metadata.py -│   │   │   │   ├── map_detail.py -│   │   │   │   ├── map_detail_tags_item.py -│   │   │   │   ├── map_detail_with_order.py -│   │   │   │   ├── map_difficulty_characteristic.py -│   │   │   │   ├── map_difficulty_difficulty.py -│   │   │   │   ├── map_difficulty.py -│   │   │   │   ├── map_parity_summary.py -│   │   │   │   ├── map_stats.py -│   │   │   │   ├── map_stats_sentiment.py -│   │   │   │   ├── map_testplay.py -│   │   │   │   ├── map_version.py -│   │   │   │   ├── map_version_state.py -│   │   │   │   ├── playlist_batch_request.py -│   │   │   │   ├── playlist_full.py -│   │   │   │   ├── playlist_full_type.py -│   │   │   │   ├── playlist_page.py -│   │   │   │   ├── playlist_search_response.py -│   │   │   │   ├── playlist_stats.py -│   │   │   │   ├── search_response.py -│   │   │   │   ├── user_detail_patreon.py -│   │   │   │   ├── user_detail.py -│   │   │   │   ├── user_detail_type.py -│   │   │   │   ├── user_diff_stats.py -│   │   │   │   ├── user_follow_data.py -│   │   │   │   ├── user_stats.py -│   │   │   │   ├── vote_request.py -│   │   │   │   └── vote_summary.py -│   │   │   ├── __pycache__ -│   │   │   ├── client.py -│   │   │   ├── errors.py -│   │   │   ├── __init__.py -│   │   │   ├── py.typed -│   │   │   └── types.py -│   │   ├── __pycache__ -│   │   ├── scoresaber -│   │   │   ├── api -│   │   │   │   ├── leaderboards -│   │   │   │   │   ├── get_api_leaderboard_by_hash_hash_info.py -│   │   │   │   │   ├── get_api_leaderboard_by_hash_hash_scores.py -│   │   │   │   │   ├── get_api_leaderboard_by_id_leaderboard_id_info.py -│   │   │   │   │   ├── get_api_leaderboard_by_id_leaderboard_id_scores.py -│   │   │   │   │   ├── get_api_leaderboard_get_difficulties_hash.py -│   │   │   │   │   ├── get_api_leaderboards.py -│   │   │   │   │   └── __init__.py -│   │   │   │   ├── nomination_assessment_team -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   ├── post_api_ranking_request_action_nat_deny.py -│   │   │   │   │   ├── post_api_ranking_request_action_nat_qualify.py -│   │   │   │   │   └── post_api_ranking_request_action_nat_replace.py -│   │   │   │   ├── players -│   │   │   │   │   ├── get_api_player_player_id_basic.py -│   │   │   │   │   ├── get_api_player_player_id_full.py -│   │   │   │   │   ├── get_api_player_player_id_scores.py -│   │   │   │   │   ├── get_api_players_count.py -│   │   │   │   │   ├── get_api_players.py -│   │   │   │   │   └── __init__.py -│   │   │   │   ├── public_ranking -│   │   │   │   │   ├── get_api_ranking_request_by_id_leaderboard_id.py -│   │   │   │   │   ├── get_api_ranking_request_request_id.py -│   │   │   │   │   ├── get_api_ranking_requests_below_top.py -│   │   │   │   │   ├── get_api_ranking_requests_top.py -│   │   │   │   │   └── __init__.py -│   │   │   │   ├── quality_assurance_team -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   ├── post_api_ranking_request_action_qat_comment.py -│   │   │   │   │   └── post_api_ranking_request_action_qat_vote.py -│   │   │   │   ├── ranking_team -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   ├── post_api_ranking_request_action_rt_comment.py -│   │   │   │   │   ├── post_api_ranking_request_action_rt_create.py -│   │   │   │   │   └── post_api_ranking_request_action_rt_vote.py -│   │   │   │   ├── website_auth -│   │   │   │   │   ├── get_api_auth_get_token.py -│   │   │   │   │   ├── get_api_auth_logout.py -│   │   │   │   │   ├── __init__.py -│   │   │   │   │   └── post_api_auth_check_token.py -│   │   │   │   ├── website_user -│   │   │   │   │   ├── get_api_user_me.py -│   │   │   │   │   ├── get_api_user_player_id_refresh.py -│   │   │   │   │   ├── get_api_user_quest_key.py -│   │   │   │   │   └── __init__.py -│   │   │   │   └── __init__.py -│   │   │   ├── models -│   │   │   │   ├── __pycache__ -│   │   │   │   ├── badge.py -│   │   │   │   ├── check_token_body.py -│   │   │   │   ├── comment.py -│   │   │   │   ├── difficulty.py -│   │   │   │   ├── get_api_player_player_id_scores_sort.py -│   │   │   │   ├── i_get_token_response.py -│   │   │   │   ├── __init__.py -│   │   │   │   ├── leaderboard_info_collection.py -│   │   │   │   ├── leaderboard_info.py -│   │   │   │   ├── leaderboard_player.py -│   │   │   │   ├── metadata.py -│   │   │   │   ├── nat_deny_body.py -│   │   │   │   ├── nat_qualify_body.py -│   │   │   │   ├── nat_replace_body.py -│   │   │   │   ├── player_badges_type_1.py -│   │   │   │   ├── player_collection.py -│   │   │   │   ├── player.py -│   │   │   │   ├── player_score_collection.py -│   │   │   │   ├── player_score.py -│   │   │   │   ├── qat_comment_body.py -│   │   │   │   ├── qat_vote_body.py -│   │   │   │   ├── ranking_difficulty.py -│   │   │   │   ├── rank_request_information.py -│   │   │   │   ├── rank_request_listing.py -│   │   │   │   ├── rt_comment_body.py -│   │   │   │   ├── rt_create_body.py -│   │   │   │   ├── rt_vote_body.py -│   │   │   │   ├── score_collection.py -│   │   │   │   ├── score.py -│   │   │   │   ├── score_saber_error.py -│   │   │   │   ├── score_stats.py -│   │   │   │   ├── user_data.py -│   │   │   │   └── vote_group.py -│   │   │   ├── __pycache__ -│   │   │   ├── client.py -│   │   │   ├── errors.py -│   │   │   ├── __init__.py -│   │   │   ├── py.typed -│   │   │   └── types.py -│   │   └── __init__.py -│   ├── pleb_saberlist.egg-info -│   │   ├── dependency_links.txt -│   │   ├── entry_points.txt -│   │   ├── PKG-INFO -│   │   ├── requires.txt -│   │   ├── SOURCES.txt -│   │   └── top_level.txt -│   ├── saberlist -│   │   ├── __pycache__ -│   │   ├── beatleader.py -│   │   ├── __init__.py -│   │   ├── PlaylistBuilder.py -│   │   └── scoresaber.py -│   └── saberlist.egg-info -│   ├── dependency_links.txt -│   ├── entry_points.txt -│   ├── PKG-INFO -│   ├── requires.txt -│   ├── SOURCES.txt -│   └── top_level.txt -├── temp_covers -├── tests -│   ├── archive -│   │   ├── __pycache__ -│   │   └── saberlist -│   │   ├── __pycache__ -│   │   └── playlist_builder.py -│   ├── assets -│   │   └── sample_cover.jpg -│   ├── __pycache__ -│   └── playlist_builder.py -├── convert-comyfui-outupt.sh -├── LICENSE -├── pyproject.toml -└── README.md - -53 directories, 328 files -``` - -We are trying to learn how to use the scoresaber client in `clients/scoresaber` that we generated using openapi-python-client. diff --git a/docs/prompts/01-scratchpad.md b/docs/prompts/01-scratchpad.md new file mode 100644 index 0000000..086dcd4 --- /dev/null +++ b/docs/prompts/01-scratchpad.md @@ -0,0 +1,543 @@ +# Python coding + +We used openapi-python-client to generate client libraries for the beatleader.xyz api. It's in clients/beatleader in our python project: + +``` +. +├── docs/ +│ ├── prompts/ +│ └── *.md +├── src/ +│ ├── clients/ +│ │ ├── beatleader/ +│ │ │ ├── api/ (various API endpoints) +│ │ │ ├── models/ (data models) +│ │ │ └── client.py, errors.py, __init__.py, types.py +│ │ ├── beatsaver/ (similar structure to beatleader) +│ │ └── scoresaber/ (similar structure to beatleader) +│ ├── helpers/ +│ │ └── *.py +│ └── saberlist/ +│ └── *.py +├── tests/ +│ ├── assets/ +│ └── playlist_builder.py +├── pyproject.toml +└── README.md +``` + +Here's the clients/beatleader dir: + +``` +treegit src/clients/beatleader/ +src/clients/beatleader/ +├── api +│   ├── beast_saber +│   │   ├── beast_saber_get_all.py +│   │   ├── beast_saber_nominate.py +│   │   └── __init__.py +│   ├── clan +│   │   ├── clan_get_all.py +│   │   ├── clan_get_clan_by_id.py +│   │   ├── clan_get_clan.py +│   │   ├── clan_get_clan_with_maps_by_id.py +│   │   ├── clan_get_clan_with_maps.py +│   │   ├── clan_get_history.py +│   │   ├── clan_global_map.py +│   │   └── __init__.py +│   ├── leaderboard +│   │   ├── __init__.py +│   │   ├── leaderboard_get_all.py +│   │   ├── leaderboard_get_clan_rankings.py +│   │   ├── leaderboard_get.py +│   │   └── leaderboard_get_scoregraph.py +│   ├── modifiers +│   │   ├── __init__.py +│   │   └── modifiers_get_modifiers.py +│   ├── patreon +│   │   ├── __init__.py +│   │   └── patreon_refresh_my_patreon.py +│   ├── player +│   │   ├── __init__.py +│   │   ├── player_get_beat_saver.py +│   │   ├── player_get_discord.py +│   │   ├── player_get_followers_info.py +│   │   ├── player_get_followers.py +│   │   ├── player_get_founded_clan.py +│   │   ├── player_get_participating_events.py +│   │   ├── player_get_patreon.py +│   │   ├── player_get_players.py +│   │   ├── player_get.py +│   │   └── player_get_ranked_maps.py +│   ├── player_scores +│   │   ├── __init__.py +│   │   ├── player_scores_acc_graph.py +│   │   ├── player_scores_get_compact_history.py +│   │   ├── player_scores_get_compact_scores.py +│   │   ├── player_scores_get_history.py +│   │   ├── player_scores_get_pinned_scores.py +│   │   ├── player_scores_get_scores.py +│   │   └── player_scores_get_score_value.py +│   ├── song +│   │   ├── __init__.py +│   │   └── song_get_all.py +│   └── __init__.py +├── models +│   ├── __pycache__ +│   ├── achievement_description.py +│   ├── achievement_level.py +│   ├── achievement.py +│   ├── badge.py +│   ├── ban.py +│   ├── beasties_nomination.py +│   ├── besties_nomination_response.py +│   ├── clan_bigger_response.py +│   ├── clan_global_map_point.py +│   ├── clan_global_map.py +│   ├── clan_map_connection.py +│   ├── clan_maps_sort_by.py +│   ├── clan_point.py +│   ├── clan.py +│   ├── clan_ranking_response_clan_response_full_response_with_metadata_and_container.py +│   ├── clan_ranking_response.py +│   ├── clan_response_full.py +│   ├── clan_response_full_response_with_metadata.py +│   ├── clan_response.py +│   ├── clan_sort_by.py +│   ├── compact_leaderboard.py +│   ├── compact_leaderboard_response.py +│   ├── compact_score.py +│   ├── compact_score_response.py +│   ├── compact_score_response_response_with_metadata.py +│   ├── compact_song_response.py +│   ├── controller_enum.py +│   ├── criteria_commentary.py +│   ├── difficulty_description.py +│   ├── difficulty_response.py +│   ├── difficulty_status.py +│   ├── event_player.py +│   ├── event_ranking.py +│   ├── external_status.py +│   ├── featured_playlist.py +│   ├── featured_playlist_response.py +│   ├── follower_type.py +│   ├── global_map_history.py +│   ├── history_compact_response.py +│   ├── hmd.py +│   ├── info_to_highlight.py +│   ├── __init__.py +│   ├── leaderboard_change.py +│   ├── leaderboard_clan_ranking_response.py +│   ├── leaderboard_contexts.py +│   ├── leaderboard_group_entry.py +│   ├── leaderboard_info_response.py +│   ├── leaderboard_info_response_response_with_metadata.py +│   ├── leaderboard.py +│   ├── leaderboard_response.py +│   ├── leaderboard_sort_by.py +│   ├── legacy_modifiers.py +│   ├── link_response.py +│   ├── map_diff_response.py +│   ├── map_info_response.py +│   ├── map_info_response_response_with_metadata.py +│   ├── mapper.py +│   ├── mapper_response.py +│   ├── map_quality.py +│   ├── map_sort_by.py +│   ├── maps_type.py +│   ├── metadata.py +│   ├── modifiers_map.py +│   ├── modifiers_rating.py +│   ├── my_type.py +│   ├── operation.py +│   ├── order.py +│   ├── participating_event_response.py +│   ├── patreon_features.py +│   ├── player_change.py +│   ├── player_context_extension.py +│   ├── player_follower.py +│   ├── player_followers_info_response.py +│   ├── player.py +│   ├── player_response_clan_response_full_response_with_metadata_and_container.py +│   ├── player_response_full.py +│   ├── player_response.py +│   ├── player_response_with_stats.py +│   ├── player_response_with_stats_response_with_metadata.py +│   ├── player_score_stats_history.py +│   ├── player_score_stats.py +│   ├── player_search.py +│   ├── player_social.py +│   ├── player_sort_by.py +│   ├── pp_type.py +│   ├── profile_settings.py +│   ├── qualification_change.py +│   ├── qualification_commentary.py +│   ├── qualification_vote.py +│   ├── ranked_mapper_response.py +│   ├── ranked_map.py +│   ├── rank_qualification.py +│   ├── rank_update_change.py +│   ├── rank_update.py +│   ├── rank_voting.py +│   ├── replay_offsets.py +│   ├── requirements.py +│   ├── score_filter_status.py +│   ├── score_graph_entry.py +│   ├── score_improvement.py +│   ├── score_metadata.py +│   ├── score_response.py +│   ├── score_response_with_acc.py +│   ├── score_response_with_my_score.py +│   ├── score_response_with_my_score_response_with_metadata.py +│   ├── scores_sort_by.py +│   ├── song.py +│   ├── song_response.py +│   ├── song_status.py +│   ├── type.py +│   └── voter_feedback.py +├── __pycache__ +├── client.py +├── errors.py +├── __init__.py +├── py.typed +└── types.py + +13 directories, 158 files +``` + +Here's the contents of `src/clients/beatleader/client.py`: + +```python +import ssl +from typing import Any, Dict, Optional, Union + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: Dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: Dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + def with_headers(self, headers: Dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: Dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: Dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: Dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: Dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + +``` + +Here is our attempt at using this client in ipython: + +```python +import json +import os +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +from clients.beatleader import client as beatleader_client +from clients.beatleader.api.player_scores import player_scores_get_compact_scores +from clients.beatleader.models.score_response_with_my_score_response_with_metadata import ScoreResponseWithMyScoreResponseWithMetadata + +logging.basicConfig( + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.DEBUG +) + +player_id = '76561199407393962' + +BASE_URL = "https://api.beatleader.xyz" +client = beatleader_client.Client(base_url=BASE_URL) + +response: ScoreResponseWithMyScoreResponseWithMetadata = player_scores_get_compact_scores.sync( + client=client, + id=player_id) +``` + +And the result: + +``` +--------------------------------------------------------------------------- +AttributeError Traceback (most recent call last) +Cell In[1], line 21 + 17 player_id = '76561199407393962' + 19 BASE_URL = "https://api.beatleader.xyz" +---> 21 response: ScoreResponseWithMyScoreResponseWithMetadata = player_scores_get_compact_scores.sync_detailed( + 22 client=beatleader_client, + 23 id=player_id) + +File ~/ops/beatsaber/playlist-tool/src/clients/beatleader/api/player_scores/player_scores_get_compact_scores.py:216, in sync_detailed(id, client, sort_by, order, page, count, search, diff, mode, requirements, score_status, leaderboard_context, type, modifiers, stars_from, stars_to, time_from, time_to, event_id) + 162 """Retrieve player's scores in a compact form + 163 + 164 Fetches a paginated list of scores for a specified player ID. Returns less info to save bandwith or + (...) + 192 Response[Union[Any, CompactScoreResponseResponseWithMetadata]] + 193 """ + 195 kwargs = _get_kwargs( + 196 id=id, + 197 sort_by=sort_by, + (...) + 213 event_id=event_id, + 214 ) +--> 216 response = client.get_httpx_client().request( + 217 **kwargs, + 218 ) + 220 return _build_response(client=client, response=response) + +AttributeError: module 'clients.beatleader.client' has no attribute 'get_httpx_client' + +``` diff --git a/docs/prompts/01-template.md b/docs/prompts/01-template.md new file mode 100644 index 0000000..7e7a98e --- /dev/null +++ b/docs/prompts/01-template.md @@ -0,0 +1,216 @@ +# Python coding + +We used openapi-python-client to generate client libraries for the beatleader.xyz api. It's in clients/beatleader in our python project: + +``` +. +├── docs/ +│ ├── prompts/ +│ └── *.md +├── src/ +│ ├── clients/ +│ │ ├── beatleader/ +│ │ │ ├── api/ (various API endpoints) +│ │ │ ├── models/ (data models) +│ │ │ └── client.py, errors.py, __init__.py, types.py +│ │ ├── beatsaver/ (similar structure to beatleader) +│ │ └── scoresaber/ (similar structure to beatleader) +│ ├── helpers/ +│ │ └── *.py +│ └── saberlist/ +│ └── *.py +├── tests/ +│ ├── assets/ +│ └── playlist_builder.py +├── pyproject.toml +└── README.md +``` + +Here's the clients/beatleader dir: + +``` +treegit src/clients/beatleader/ +src/clients/beatleader/ +├── api +│   ├── beast_saber +│   │   ├── beast_saber_get_all.py +│   │   ├── beast_saber_nominate.py +│   │   └── __init__.py +│   ├── clan +│   │   ├── clan_get_all.py +│   │   ├── clan_get_clan_by_id.py +│   │   ├── clan_get_clan.py +│   │   ├── clan_get_clan_with_maps_by_id.py +│   │   ├── clan_get_clan_with_maps.py +│   │   ├── clan_get_history.py +│   │   ├── clan_global_map.py +│   │   └── __init__.py +│   ├── leaderboard +│   │   ├── __init__.py +│   │   ├── leaderboard_get_all.py +│   │   ├── leaderboard_get_clan_rankings.py +│   │   ├── leaderboard_get.py +│   │   └── leaderboard_get_scoregraph.py +│   ├── modifiers +│   │   ├── __init__.py +│   │   └── modifiers_get_modifiers.py +│   ├── patreon +│   │   ├── __init__.py +│   │   └── patreon_refresh_my_patreon.py +│   ├── player +│   │   ├── __init__.py +│   │   ├── player_get_beat_saver.py +│   │   ├── player_get_discord.py +│   │   ├── player_get_followers_info.py +│   │   ├── player_get_followers.py +│   │   ├── player_get_founded_clan.py +│   │   ├── player_get_participating_events.py +│   │   ├── player_get_patreon.py +│   │   ├── player_get_players.py +│   │   ├── player_get.py +│   │   └── player_get_ranked_maps.py +│   ├── player_scores +│   │   ├── __init__.py +│   │   ├── player_scores_acc_graph.py +│   │   ├── player_scores_get_compact_history.py +│   │   ├── player_scores_get_compact_scores.py +│   │   ├── player_scores_get_history.py +│   │   ├── player_scores_get_pinned_scores.py +│   │   ├── player_scores_get_scores.py +│   │   └── player_scores_get_score_value.py +│   ├── song +│   │   ├── __init__.py +│   │   └── song_get_all.py +│   └── __init__.py +├── models +│   ├── __pycache__ +│   ├── achievement_description.py +│   ├── achievement_level.py +│   ├── achievement.py +│   ├── badge.py +│   ├── ban.py +│   ├── beasties_nomination.py +│   ├── besties_nomination_response.py +│   ├── clan_bigger_response.py +│   ├── clan_global_map_point.py +│   ├── clan_global_map.py +│   ├── clan_map_connection.py +│   ├── clan_maps_sort_by.py +│   ├── clan_point.py +│   ├── clan.py +│   ├── clan_ranking_response_clan_response_full_response_with_metadata_and_container.py +│   ├── clan_ranking_response.py +│   ├── clan_response_full.py +│   ├── clan_response_full_response_with_metadata.py +│   ├── clan_response.py +│   ├── clan_sort_by.py +│   ├── compact_leaderboard.py +│   ├── compact_leaderboard_response.py +│   ├── compact_score.py +│   ├── compact_score_response.py +│   ├── compact_score_response_response_with_metadata.py +│   ├── compact_song_response.py +│   ├── controller_enum.py +│   ├── criteria_commentary.py +│   ├── difficulty_description.py +│   ├── difficulty_response.py +│   ├── difficulty_status.py +│   ├── event_player.py +│   ├── event_ranking.py +│   ├── external_status.py +│   ├── featured_playlist.py +│   ├── featured_playlist_response.py +│   ├── follower_type.py +│   ├── global_map_history.py +│   ├── history_compact_response.py +│   ├── hmd.py +│   ├── info_to_highlight.py +│   ├── __init__.py +│   ├── leaderboard_change.py +│   ├── leaderboard_clan_ranking_response.py +│   ├── leaderboard_contexts.py +│   ├── leaderboard_group_entry.py +│   ├── leaderboard_info_response.py +│   ├── leaderboard_info_response_response_with_metadata.py +│   ├── leaderboard.py +│   ├── leaderboard_response.py +│   ├── leaderboard_sort_by.py +│   ├── legacy_modifiers.py +│   ├── link_response.py +│   ├── map_diff_response.py +│   ├── map_info_response.py +│   ├── map_info_response_response_with_metadata.py +│   ├── mapper.py +│   ├── mapper_response.py +│   ├── map_quality.py +│   ├── map_sort_by.py +│   ├── maps_type.py +│   ├── metadata.py +│   ├── modifiers_map.py +│   ├── modifiers_rating.py +│   ├── my_type.py +│   ├── operation.py +│   ├── order.py +│   ├── participating_event_response.py +│   ├── patreon_features.py +│   ├── player_change.py +│   ├── player_context_extension.py +│   ├── player_follower.py +│   ├── player_followers_info_response.py +│   ├── player.py +│   ├── player_response_clan_response_full_response_with_metadata_and_container.py +│   ├── player_response_full.py +│   ├── player_response.py +│   ├── player_response_with_stats.py +│   ├── player_response_with_stats_response_with_metadata.py +│   ├── player_score_stats_history.py +│   ├── player_score_stats.py +│   ├── player_search.py +│   ├── player_social.py +│   ├── player_sort_by.py +│   ├── pp_type.py +│   ├── profile_settings.py +│   ├── qualification_change.py +│   ├── qualification_commentary.py +│   ├── qualification_vote.py +│   ├── ranked_mapper_response.py +│   ├── ranked_map.py +│   ├── rank_qualification.py +│   ├── rank_update_change.py +│   ├── rank_update.py +│   ├── rank_voting.py +│   ├── replay_offsets.py +│   ├── requirements.py +│   ├── score_filter_status.py +│   ├── score_graph_entry.py +│   ├── score_improvement.py +│   ├── score_metadata.py +│   ├── score_response.py +│   ├── score_response_with_acc.py +│   ├── score_response_with_my_score.py +│   ├── score_response_with_my_score_response_with_metadata.py +│   ├── scores_sort_by.py +│   ├── song.py +│   ├── song_response.py +│   ├── song_status.py +│   ├── type.py +│   └── voter_feedback.py +├── __pycache__ +├── client.py +├── errors.py +├── __init__.py +├── py.typed +└── types.py + +13 directories, 158 files +``` + +Here's the contents of ``: + +```python +``` + +Here our attempt at using this client in ipython: + +```python +``` diff --git a/docs/prompts/02-sample-usage.md b/docs/prompts/02-sample-usage.md deleted file mode 100644 index d292c2d..0000000 --- a/docs/prompts/02-sample-usage.md +++ /dev/null @@ -1,41 +0,0 @@ -We have some sample usage of this library here: - - -``` -In [40]: client = ScoreSaberClient(base_url="https://scoresaber.com") - ...: - ...: player_id = '76561199407393962' - ...: - ...: player_score_collection: PlayerScoreCollection = get_api_player_player_id_scores.sync( - ...: client=client, - ...: player_id=player_id, - ...: # page=1, - ...: # limit=50, - ...: # sort='recent' - ...: ) - ...: -2024-09-30 09:30:49 DEBUG: load_ssl_context verify=True cert=None trust_env=True http2=False -2024-09-30 09:30:49 DEBUG: load_verify_locations cafile='/home/blee/ops/beatsaber/playlist-tool/.venv/lib/python3.11/site-packages/certifi/cacert.pem' -2024-09-30 09:30:49 DEBUG: connect_tcp.started host='scoresaber.com' port=443 local_address=None timeout=None socket_options=None -2024-09-30 09:30:49 DEBUG: connect_tcp.complete return_value= -2024-09-30 09:30:49 DEBUG: start_tls.started ssl_context= server_hostname='scoresaber.com' timeout=None -2024-09-30 09:30:49 DEBUG: start_tls.complete return_value= -2024-09-30 09:30:49 DEBUG: send_request_headers.started request= -2024-09-30 09:30:49 DEBUG: send_request_headers.complete -2024-09-30 09:30:49 DEBUG: send_request_body.started request= -2024-09-30 09:30:49 DEBUG: send_request_body.complete -2024-09-30 09:30:49 DEBUG: receive_response_headers.started request= -2024-09-30 09:30:49 DEBUG: receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Date', b'Mon, 30 Sep 2024 16:30:49 GMT'), (b'Content-Type', b'application/json; charset=utf-8'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Content-Security-Policy', b"default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests"), (b'Cross-Origin-Embedder-Policy', b'require-corp'), (b'Cross-Origin-Opener-Policy', b'same-origin'), (b'Cross-Origin-Resource-Policy', b'same-origin'), (b'Origin-Agent-Cluster', b'?1'), (b'Referrer-Policy', b'no-referrer'), (b'Strict-Transport-Security', b'max-age=15552000; includeSubDomains'), (b'X-Content-Type-Options', b'nosniff'), (b'X-DNS-Prefetch-Control', b'off'), (b'X-Download-Options', b'noopen'), (b'X-Frame-Options', b'SAMEORIGIN'), (b'X-Permitted-Cross-Domain-Policies', b'none'), (b'X-XSS-Protection', b'0'), (b'X-RateLimit-Limit', b'400'), (b'X-RateLimit-Remaining', b'399'), (b'X-RateLimit-Reset', b'1727713862'), (b'ETag', b'W/"3369-agcCR95tngk5ACxBE2z3E87S8IM"'), (b'CF-Cache-Status', b'DYNAMIC'), (b'Report-To', b'{"endpoints":[{"url":"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=3WgwWJyhnP7LHZaTLYLst9jpVmzOj5bhgASSmRqf97BJDejCgVN2K%2BD1XI79HkY4TAXyQVzNxgYWid9iMXy2lmS0q6QVpYZKakJZhoPOj1GEKQeV22VgLUfKXsgPbxK2aQ%3D%3D"}],"group":"cf-nel","max_age":604800}'), (b'NEL', b'{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'), (b'Server', b'cloudflare'), (b'CF-RAY', b'8cb59986999b0cbb-LAX'), (b'Content-Encoding', b'gzip')]) -2024-09-30 09:30:49 INFO: HTTP Request: GET https://scoresaber.com/api/player/76561199407393962/scores "HTTP/1.1 200 OK" -2024-09-30 09:30:49 DEBUG: receive_response_body.started request= -2024-09-30 09:30:49 DEBUG: receive_response_body.complete -2024-09-30 09:30:49 DEBUG: response_closed.started -2024-09-30 09:30:49 DEBUG: response_closed.complete - -2024-09-30 09:30:49 DEBUG: Using selector: EpollSelector -In [41]: player_score_collection -Out[41]: PlayerScoreCollection(player_scores=[PlayerScore(score=Score(id=84950667, rank=104, base_score=1099231, modified_score=1099231, pp=341.7099, weight=1, modifiers='', multiplier=1, bad_cuts=0, missed_notes=2, max_combo=775, full_combo=False, hmd=0, has_replay=True, time_set=datetime.datetime(2024, 9, 5, 1, 8, 57, tzinfo=tzutc()), device_hmd='Vive', device_controller_left='Touch', device_controller_right='Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=617336, song_hash='CEE98A6F3D644468E231D385CA609F804977583A', song_name='Unlimited Hyperlink', song_sub_name='', song_author_name='Kobaryo', level_author_name='Timbo', difficulty=Difficulty(leaderboard_id=617336, difficulty=5, game_mode='SoloStandard', difficulty_raw='_Hard_SoloStandard'), max_score=1165755, created_date=datetime.datetime(2024, 7, 20, 0, 17, 12, tzinfo=tzutc()), ranked_date=datetime.datetime(2024, 9, 2, 17, 38, 15, tzinfo=tzutc()), qualified_date=datetime.datetime(2024, 8, 4, 7, 39, 33, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=8.46, positive_modifiers=False, plays=357, daily_plays=10, cover_image='https://cdn.scoresaber.com/covers/CEE98A6F3D644468E231D385CA609F804977583A.png', player_score=None, difficulties=[], additional_properties={})), PlayerScore(score=Score(id=84877397, rank=556, base_score=1440841, modified_score=1440841, pp=313.5614, weight=0.965, modifiers='', multiplier=1, bad_cuts=1, missed_notes=1, max_combo=979, full_combo=False, hmd=0, has_replay=False, time_set=datetime.datetime(2024, 9, 3, 0, 40, 42, tzinfo=tzutc()), device_hmd='Vive', device_controller_left='Touch', device_controller_right='Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=292442, song_hash='5EEA46E777960913951ED9D0257404CC3A1BE305', song_name='Memecore', song_sub_name='', song_author_name='HiTECH NINJA', level_author_name='FatBeanzoop', difficulty=Difficulty(leaderboard_id=292442, difficulty=7, game_mode='SoloStandard', difficulty_raw='_Expert_SoloStandard'), max_score=1541115, created_date=datetime.datetime(2020, 9, 10, 3, 12, 50, tzinfo=tzutc()), ranked_date=datetime.datetime(2020, 9, 15, 9, 27, 51, tzinfo=tzutc()), qualified_date=datetime.datetime(2020, 9, 10, 16, 7, 16, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=8.07, positive_modifiers=False, plays=5560, daily_plays=4, cover_image='https://cdn.scoresaber.com/covers/5EEA46E777960913951ED9D0257404CC3A1BE305.png', player_score=None, difficulties=[], additional_properties={})), PlayerScore(score=Score(id=83361463, rank=132, base_score=1363346, modified_score=1363346, pp=306.9723, weight=0.931225, modifiers='', multiplier=1, bad_cuts=0, missed_notes=0, max_combo=1572, full_combo=True, hmd=0, has_replay=True, time_set=datetime.datetime(2024, 8, 15, 23, 10, 29, tzinfo=tzutc()), device_hmd='Vive', device_controller_left='Touch', device_controller_right='Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=299718, song_hash='A51425319D892986A4867388153BCE45B3C28483', song_name='Shake The Ground', song_sub_name='(feat. Sullivan King & Jonah Kay)', song_author_name='Kill The Noise & SNAILS', level_author_name='Checkthepan', difficulty=Difficulty(leaderboard_id=299718, difficulty=7, game_mode='SoloStandard', difficulty_raw='_Expert_SoloStandard'), max_score=1438995, created_date=datetime.datetime(2020, 10, 9, 0, 26, 9, tzinfo=tzutc()), ranked_date=datetime.datetime(2020, 10, 16, 15, 28, 19, tzinfo=tzutc()), qualified_date=datetime.datetime(2020, 10, 11, 16, 10, 9, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=7.4, positive_modifiers=False, plays=3317, daily_plays=1, cover_image='https://cdn.scoresaber.com/covers/A51425319D892986A4867388153BCE45B3C28483.png', player_score=None, difficulties=[], additional_properties={})), PlayerScore(score=Score(id=83135043, rank=512, base_score=884286, modified_score=884286, pp=302.4246, weight=0.8986321249999999, modifiers='', multiplier=1, bad_cuts=0, missed_notes=2, max_combo=797, full_combo=False, hmd=0, has_replay=True, time_set=datetime.datetime(2024, 5, 1, 22, 58, 43, tzinfo=tzutc()), device_hmd='Quest 2', device_controller_left='Quest 2 Touch', device_controller_right='Quest 2 Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=320372, song_hash='62128DFDD39DB6A2CC380C254031A9D4367B87A1', song_name='Nibelungen', song_sub_name='', song_author_name='Gram', level_author_name='ComplexFrequency', difficulty=Difficulty(leaderboard_id=320372, difficulty=5, game_mode='SoloStandard', difficulty_raw='_Hard_SoloStandard'), max_score=937595, created_date=datetime.datetime(2021, 1, 11, 2, 21, 1, tzinfo=tzutc()), ranked_date=datetime.datetime(2021, 1, 22, 5, 8, 50, tzinfo=tzutc()), qualified_date=datetime.datetime(2021, 1, 11, 2, 57, 56, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=7.48, positive_modifiers=False, plays=5915, daily_plays=4, cover_image='https://cdn.scoresaber.com/covers/62128DFDD39DB6A2CC380C254031A9D4367B87A1.png', player_score=None, difficulties=[], additional_properties={})), PlayerScore(score=Score(id=82951813, rank=225, base_score=1063732, modified_score=1063732, pp=299.8663, weight=0.8671800006249999, modifiers='', multiplier=1, bad_cuts=0, missed_notes=0, max_combo=1228, full_combo=True, hmd=0, has_replay=True, time_set=datetime.datetime(2024, 9, 17, 21, 0, 28, tzinfo=tzutc()), device_hmd='Vive', device_controller_left='Touch', device_controller_right='Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=290861, song_hash='2E6CB362F31D00EE9F2B9C8640CFD94BBFB8377F', song_name='Astronomia', song_sub_name='(Camellia Remix)', song_author_name='Vicetone and Tony Igy', level_author_name='That_Narwhal', difficulty=Difficulty(leaderboard_id=290861, difficulty=7, game_mode='SoloStandard', difficulty_raw='_Expert_SoloStandard'), max_score=1122515, created_date=datetime.datetime(2020, 9, 3, 4, 19, 55, tzinfo=tzutc()), ranked_date=datetime.datetime(2020, 9, 8, 12, 35, 3, tzinfo=tzutc()), qualified_date=datetime.datetime(2020, 9, 3, 13, 24, 17, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=7.22, positive_modifiers=False, plays=5830, daily_plays=3, cover_image='https://cdn.scoresaber.com/covers/2E6CB362F31D00EE9F2B9C8640CFD94BBFB8377F.png', player_score=None, difficulties=[], additional_properties={})), PlayerScore(score=Score(id=82951898, rank=284, base_score=1190738, modified_score=1190738, pp=299.2155, weight=0.8368287006031249, modifiers='', multiplier=1, bad_cuts=1, missed_notes=2, max_combo=900, full_combo=False, hmd=0, has_replay=True, time_set=datetime.datetime(2024, 4, 12, 22, 25, 4, tzinfo=tzutc()), device_hmd='Quest 2', device_controller_left='Quest 2 Touch', device_controller_right='Quest 2 Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=198670, song_hash='9E6DEB75CA74B96497BC5EF0BB5B98EE5C0B7E69', song_name='Twisted Drop Party', song_sub_name='', song_author_name='t+pazolite & Getty', level_author_name='Depito & ExUnReal', difficulty=Difficulty(leaderboard_id=198670, difficulty=7, game_mode='SoloStandard', difficulty_raw='_Expert_SoloStandard'), max_score=1267875, created_date=datetime.datetime(2020, 1, 21, 17, 27, 38, tzinfo=tzutc()), ranked_date=datetime.datetime(2020, 4, 9, 18, 0, 31, tzinfo=tzutc()), qualified_date=datetime.datetime(2020, 4, 5, 0, 59, 34, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=7.57, positive_modifiers=False, plays=3988, daily_plays=1, cover_image='https://cdn.scoresaber.com/covers/9E6DEB75CA74B96497BC5EF0BB5B98EE5C0B7E69.png', player_score=None, difficulties=[], additional_properties={})), PlayerScore(score=Score(id=84617098, rank=268, base_score=695542, modified_score=695542, pp=298.7397, weight=0.8075396960820155, modifiers='', multiplier=1, bad_cuts=0, missed_notes=0, max_combo=816, full_combo=True, hmd=0, has_replay=True, time_set=datetime.datetime(2024, 8, 16, 23, 28, 18, tzinfo=tzutc()), device_hmd='Vive', device_controller_left='Touch', device_controller_right='Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=597749, song_hash='61FD5B17CBA13ABE1E76591121E1AC7FE8A5F425', song_name='ANALYS', song_sub_name='', song_author_name='HAYAKO', level_author_name='RetrX', difficulty=Difficulty(leaderboard_id=597749, difficulty=7, game_mode='SoloStandard', difficulty_raw='_Expert_SoloStandard'), max_score=743475, created_date=datetime.datetime(2024, 3, 2, 5, 38, 26, tzinfo=tzutc()), ranked_date=datetime.datetime(2024, 3, 18, 3, 3, 12, tzinfo=tzutc()), qualified_date=datetime.datetime(2024, 3, 4, 19, 50, 44, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=7.67, positive_modifiers=False, plays=1253, daily_plays=3, cover_image='https://cdn.scoresaber.com/covers/61FD5B17CBA13ABE1E76591121E1AC7FE8A5F425.png', player_score=None, difficulties=[], additional_properties={})), PlayerScore(score=Score(id=83669721, rank=1373, base_score=1687417, modified_score=1687417, pp=298.7396, weight=0.7792758067191449, modifiers='', multiplier=1, bad_cuts=0, missed_notes=4, max_combo=764, full_combo=False, hmd=0, has_replay=False, time_set=datetime.datetime(2024, 9, 6, 21, 32, 52, tzinfo=tzutc()), device_hmd='Vive', device_controller_left='Touch', device_controller_right='Touch', leaderboard_player_info=None), leaderboard=LeaderboardInfo(id=368474, song_hash='9E3CD9B0C5559167E31E6894C14E391AEA937991', song_name='KillerToy', song_sub_name='', song_author_name='Camellia', level_author_name='Jabob', difficulty=Difficulty(leaderboard_id=368474, difficulty=7, game_mode='SoloStandard', difficulty_raw='_Expert_SoloStandard'), max_score=1818955, created_date=datetime.datetime(2021, 7, 14, 17, 31, 30, tzinfo=tzutc()), ranked_date=datetime.datetime(2021, 9, 19, 16, 56, 7, tzinfo=tzutc()), qualified_date=datetime.datetime(2021, 9, 10, 18, 53, 58, tzinfo=tzutc()), loved_date=None, ranked=True, qualified=False, loved=False, max_pp=-1, stars=7.91, positive_modifiers=False, plays=6698, daily_plays=8, cover_image='https://cdn.scoresaber.com/covers/9E3CD9B0C5559167E31E6894C14E391AEA937991.png', player_score=None, difficulties=[], additional_properties={}))], metadata=Metadata(total=961, page=1, items_per_page=8)) - -2024-09-30 09:31:03 DEBUG: Using selector: EpollSelector -``` - diff --git a/docs/prompts/02-scratchpad.md b/docs/prompts/02-scratchpad.md new file mode 100644 index 0000000..9d24552 --- /dev/null +++ b/docs/prompts/02-scratchpad.md @@ -0,0 +1,180 @@ +# New Python Class + +We are working on a python class that wraps an openapi client generated by `openapi-python-client`. The class will handle caching and pagination for a specific API endpoint. + +```python +import json +import os +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +from clients.beatleader import client as beatleader_client +from clients.beatleader.api.player_scores import player_scores_get_compact_scores +from clients.beatleader.models.score_response_with_my_score_response_with_metadata import ScoreResponseWithMyScoreResponseWithMetadata + +logging.basicConfig( + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.DEBUG +) + +class BeatLeaderAPI: + BASE_URL = "https://api.beatleader.xyz" + + def __init__(self, cache_expiry_days: int = 1, cache_dir: Optional[str] = None): + self.client = beatleader_client.Client(base_url=self.BASE_URL) + self.cache_expiry_days = cache_expiry_days + self.CACHE_DIR = cache_dir or self._determine_cache_dir() + if not os.path.exists(self.CACHE_DIR): + os.makedirs(self.CACHE_DIR) + logging.info(f"Created cache directory: {self.CACHE_DIR}") + + def _determine_cache_dir(self) -> str: + home_cache = os.path.expanduser("~/.cache") + beatleader_cache = os.path.join(home_cache, "beatleader") + + if os.path.exists(home_cache): + if not os.path.exists(beatleader_cache): + try: + os.makedirs(beatleader_cache) + logging.info(f"Created cache directory: {beatleader_cache}") + except OSError as e: + logging.warning(f"Failed to create {beatleader_cache}: {e}") + return os.path.join(os.getcwd(), ".cache") + return beatleader_cache + else: + logging.info("~/.cache doesn't exist, using local .cache directory") + return os.path.join(os.getcwd(), ".cache") + + def _get_cache_filename(self, player_id: str) -> str: + return os.path.join(self.CACHE_DIR, f"player_{player_id}_scores.json") + + def _is_cache_valid(self, cache_file: str) -> bool: + if not os.path.exists(cache_file): + return False + file_modified_time = datetime.fromtimestamp(os.path.getmtime(cache_file)) + return datetime.now() - file_modified_time < timedelta(days=self.cache_expiry_days) + + """TODO: + def get_player_scores( + self, + player_id: str, + use_cache: bool = True, + limit: int = 100, + sort: str = "recent", + max_pages: Optional[int] = None + ) -> Dict[str, Any]: + """ + Fetches all player scores for a given player ID, handling pagination and caching. + + """ + cache_file = self._get_cache_filename(player_id) + + if use_cache and self._is_cache_valid(cache_file): + logging.debug(f"Using cached data for player {player_id}") + with open(cache_file, 'r') as f: + return json.load(f) + + logging.debug(f"Fetching fresh data for player {player_id}") + + all_scores = [] + page = 1 + total_items = None + + while max_pages is None or page <= max_pages: + try: + response: ScoreResponseWithMyScoreResponseWithMetadata = player_scores_get_compact_scores.sync( + client=self.client, + id=player_id, + page=page, + limit=limit, + sort=sort + ) + except Exception as e: + logging.error(f"Error fetching page {page} for player {player_id}: {e}") + return {"metadata": {}, "playerScores": []} + + all_scores.extend([score.dict() for score in response.player_scores]) + + if total_items is None: + total_items = response.metadata.total + logging.debug(f"Total scores to fetch: {total_items}") + + logging.debug(f"Fetched page {page}: {len(response.player_scores)} scores") + + if len(all_scores) >= total_items: + break + + page += 1 + + result = { + 'metadata': response.metadata.dict(), + 'playerScores': all_scores + } + + with open(cache_file, 'w') as f: + json.dump(result, f, default=str) # default=str to handle datetime serialization + + logging.info(f"Cached scores for player {player_id} at {cache_file}") + + return result + """ + +``` + +This class is a draft and not yet tested. We don't know what the proper attributes for limit and sort are yet. we also just have a mockup of a get_player_scores() method. + +Here is a sample of the response data: + +`In [1]: import json + ...: import os + ...: import logging + ...: from datetime import datetime, timedelta + ...: from typing import Optional, Dict, Any + ...: + ...: from clients.beatleader import client as beatleader_client + ...: from clients.beatleader.api.player_scores import player_scores_get_compact_scores + ...: from clients.beatleader.models.score_response_with_my_score_response_with_metadata import ScoreResponseWithMyScoreResponseWithMetadata + ...: + ...: logging.basicConfig( + ...: format='%(asctime)s %(levelname)s: %(message)s', + ...: datefmt='%Y-%m-%d %H:%M:%S', + ...: level=logging.DEBUG + ...: ) + ...: + ...: player_id = '76561199407393962' + ...: + ...: BASE_URL = "https://api.beatleader.xyz" + ...: client = beatleader_client.Client(base_url=BASE_URL) + ...: + ...: response: ScoreResponseWithMyScoreResponseWithMetadata = player_scores_get_compact_scores.sync_detailed( + ...: client=client, + ...: id=player_id) +2024-10-04 10:18:23 DEBUG: load_ssl_context verify=True cert=None trust_env=True http2=False +2024-10-04 10:18:23 DEBUG: load_verify_locations cafile='/home/blee/ops/beatsaber/playlist-tool/.venv/lib/python3.11/site-packages/certifi/cacert.pem' +2024-10-04 10:18:23 DEBUG: connect_tcp.started host='api.beatleader.xyz' port=443 local_address=None timeout=None socket_options=None +2024-10-04 10:18:23 DEBUG: connect_tcp.complete return_value= +2024-10-04 10:18:23 DEBUG: start_tls.started ssl_context= server_hostname='api.beatleader.xyz' timeout=None +2024-10-04 10:18:23 DEBUG: start_tls.complete return_value= +2024-10-04 10:18:23 DEBUG: send_request_headers.started request= +2024-10-04 10:18:23 DEBUG: send_request_headers.complete +2024-10-04 10:18:23 DEBUG: send_request_body.started request= +2024-10-04 10:18:23 DEBUG: send_request_body.complete +2024-10-04 10:18:23 DEBUG: receive_response_headers.started request= +2024-10-04 10:18:23 DEBUG: receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Date', b'Fri, 04 Oct 2024 17:18:23 GMT'), (b'Content-Type', b'application/json; charset=utf-8'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Accept-Encoding'), (b'X-Rate-Limit-Limit', b'10s'), (b'X-Rate-Limit-Remaining', b'49'), (b'X-Rate-Limit-Reset', b'2024-10-04T17:18:33.2883475Z'), (b'Server-Timing', b'db;dur=88'), (b'CF-Cache-Status', b'DYNAMIC'), (b'Report-To', b'{"endpoints":[{"url":"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=%2FAFmlHN%2BCrRbTVTzRB3nlDn%2BqlY9h%2B6qtRXYD9M3s%2F8ify3z37wkIRXw0AExoIyf%2BLrAl20fxLf1HGqt7UtMHeW7JZT%2BjqQ9nDmp0zZarZcfOMfVKF%2B9mI8qCuBD1%2BjtBX9CoA%3D%3D"}],"group":"cf-nel","max_age":604800}'), (b'NEL', b'{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'), (b'Server', b'cloudflare'), (b'CF-RAY', b'8cd6d4b329370fbc-LAX'), (b'Content-Encoding', b'gzip')]) +2024-10-04 10:18:23 INFO: HTTP Request: GET https://api.beatleader.xyz/player/76561199407393962/scores/compact?page=1&count=8 "HTTP/1.1 200 OK" +2024-10-04 10:18:23 DEBUG: receive_response_body.started request= +2024-10-04 10:18:23 DEBUG: receive_response_body.complete +2024-10-04 10:18:23 DEBUG: response_closed.started +2024-10-04 10:18:23 DEBUG: response_closed.complete + +2024-10-04 10:18:23 DEBUG: Using selector: EpollSelector +In [2]: response +Out[2]: Response(status_code=, content=b'{"metadata":{"itemsPerPage":8,"page":1,"total":1110},"data":[{"score":{"id":18223233,"baseScore":188988,"modifiedScore":188988,"modifiers":"","fullCombo":true,"maxCombo":217,"missedNotes":0,"badCuts":0,"hmd":512,"controller":1,"accuracy":0.98229164,"pp":0,"epochTime":1727913725},"leaderboard":{"id":"3ae7f51","songHash":"F4D70651577A6DB4F906762393E0FC6809F22FED","modeName":"Standard","difficulty":5}},{"score":{"id":18222857,"baseScore":937464,"modifiedScore":937464,"modifiers":"","fullCombo":false,"maxCombo":600,"missedNotes":0,"badCuts":1,"hmd":512,"controller":1,"accuracy":0.9416496,"pp":392.4357,"epochTime":1727912395},"leaderboard":{"id":"e29891","songHash":"02e42bb3280e0ea52829a4a2bf47f3eb8a3e32eb","modeName":"Standard","difficulty":9}},{"score":{"id":18222373,"baseScore":503901,"modifiedScore":503901,"modifiers":"PM","fullCombo":true,"maxCombo":578,"missedNotes":0,"badCuts":0,"hmd":512,"controller":1,"accuracy":0.9606989,"pp":216.59628,"epochTime":1727910736},"leaderboard":{"id":"13fe111","songHash":"7bdd78a1787e0fd59a24466d700e1683b1cf5de4","modeName":"Standard","difficulty":1}},{"score":{"id":18222211,"baseScore":307804,"modifiedScore":307804,"modifiers":"","fullCombo":true,"maxCombo":351,"missedNotes":0,"badCuts":0,"hmd":512,"controller":1,"accuracy":0.9750661,"pp":169.61502,"epochTime":1727910142},"leaderboard":{"id":"11c9c11","songHash":"4dfaf66b4a2e78e1b87d7a83634ee322afd270c5","modeName":"Standard","difficulty":1}},{"score":{"id":18222140,"baseScore":107899,"modifiedScore":107899,"modifiers":"","fullCombo":true,"maxCombo":128,"missedNotes":0,"badCuts":0,"hmd":512,"controller":1,"accuracy":0.976329,"pp":157.81444,"epochTime":1727909912},"leaderboard":{"id":"d73511","songHash":"e6e02417e730ad6408fbe6363e99efd462083070","modeName":"Standard","difficulty":1}},{"score":{"id":18203563,"baseScore":937173,"modifiedScore":937173,"modifiers":"","fullCombo":false,"maxCombo":565,"missedNotes":5,"badCuts":0,"hmd":512,"controller":1,"accuracy":0.9234369,"pp":335.5832,"epochTime":1727827105},"leaderboard":{"id":"ca3051","songHash":"d44de2eebd64f3cfa70c024fabb042bf73a43f41","modeName":"Standard","difficulty":5}},{"score":{"id":18202861,"baseScore":1364040,"modifiedScore":1364040,"modifiers":"","fullCombo":false,"maxCombo":678,"missedNotes":6,"badCuts":1,"hmd":512,"controller":1,"accuracy":0.9236989,"pp":337.5511,"epochTime":1727824695},"leaderboard":{"id":"11b4991","songHash":"09f8bee6908e3a9cd724b3db3162a5c381ecb156","modeName":"Standard","difficulty":9}},{"score":{"id":18202637,"baseScore":397686,"modifiedScore":397686,"modifiers":"","fullCombo":true,"maxCombo":456,"missedNotes":0,"badCuts":0,"hmd":512,"controller":1,"accuracy":0.96461344,"pp":0,"epochTime":1727823927},"leaderboard":{"id":"21e2151","songHash":"1262e162a207aa7fbcf18f18eaf5a612a35f4139","modeName":"Standard","difficulty":5}}]}', headers=Headers({'date': 'Fri, 04 Oct 2024 17:18:23 GMT', 'content-type': 'application/json; charset=utf-8', 'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'vary': 'Accept-Encoding', 'x-rate-limit-limit': '10s', 'x-rate-limit-remaining': '49', 'x-rate-limit-reset': '2024-10-04T17:18:33.2883475Z', 'server-timing': 'db;dur=88', 'cf-cache-status': 'DYNAMIC', 'report-to': '{"endpoints":[{"url":"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=%2FAFmlHN%2BCrRbTVTzRB3nlDn%2BqlY9h%2B6qtRXYD9M3s%2F8ify3z37wkIRXw0AExoIyf%2BLrAl20fxLf1HGqt7UtMHeW7JZT%2BjqQ9nDmp0zZarZcfOMfVKF%2B9mI8qCuBD1%2BjtBX9CoA%3D%3D"}],"group":"cf-nel","max_age":604800}', 'nel': '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}', 'server': 'cloudflare', 'cf-ray': '8cd6d4b329370fbc-LAX', 'content-encoding': 'gzip'}), parsed=CompactScoreResponseResponseWithMetadata(metadata=Metadata(items_per_page=8, page=1, total=1110), data=[{'score': {'id': 18223233, 'baseScore': 188988, 'modifiedScore': 188988, 'modifiers': '', 'fullCombo': True, 'maxCombo': 217, 'missedNotes': 0, 'badCuts': 0, 'hmd': 512, 'controller': 1, 'accuracy': 0.98229164, 'pp': 0, 'epochTime': 1727913725}, 'leaderboard': {'id': '3ae7f51', 'songHash': 'F4D70651577A6DB4F906762393E0FC6809F22FED', 'modeName': 'Standard', 'difficulty': 5}}, {'score': {'id': 18222857, 'baseScore': 937464, 'modifiedScore': 937464, 'modifiers': '', 'fullCombo': False, 'maxCombo': 600, 'missedNotes': 0, 'badCuts': 1, 'hmd': 512, 'controller': 1, 'accuracy': 0.9416496, 'pp': 392.4357, 'epochTime': 1727912395}, 'leaderboard': {'id': 'e29891', 'songHash': '02e42bb3280e0ea52829a4a2bf47f3eb8a3e32eb', 'modeName': 'Standard', 'difficulty': 9}}, {'score': {'id': 18222373, 'baseScore': 503901, 'modifiedScore': 503901, 'modifiers': 'PM', 'fullCombo': True, 'maxCombo': 578, 'missedNotes': 0, 'badCuts': 0, 'hmd': 512, 'controller': 1, 'accuracy': 0.9606989, 'pp': 216.59628, 'epochTime': 1727910736}, 'leaderboard': {'id': '13fe111', 'songHash': '7bdd78a1787e0fd59a24466d700e1683b1cf5de4', 'modeName': 'Standard', 'difficulty': 1}}, {'score': {'id': 18222211, 'baseScore': 307804, 'modifiedScore': 307804, 'modifiers': '', 'fullCombo': True, 'maxCombo': 351, 'missedNotes': 0, 'badCuts': 0, 'hmd': 512, 'controller': 1, 'accuracy': 0.9750661, 'pp': 169.61502, 'epochTime': 1727910142}, 'leaderboard': {'id': '11c9c11', 'songHash': '4dfaf66b4a2e78e1b87d7a83634ee322afd270c5', 'modeName': 'Standard', 'difficulty': 1}}, {'score': {'id': 18222140, 'baseScore': 107899, 'modifiedScore': 107899, 'modifiers': '', 'fullCombo': True, 'maxCombo': 128, 'missedNotes': 0, 'badCuts': 0, 'hmd': 512, 'controller': 1, 'accuracy': 0.976329, 'pp': 157.81444, 'epochTime': 1727909912}, 'leaderboard': {'id': 'd73511', 'songHash': 'e6e02417e730ad6408fbe6363e99efd462083070', 'modeName': 'Standard', 'difficulty': 1}}, {'score': {'id': 18203563, 'baseScore': 937173, 'modifiedScore': 937173, 'modifiers': '', 'fullCombo': False, 'maxCombo': 565, 'missedNotes': 5, 'badCuts': 0, 'hmd': 512, 'controller': 1, 'accuracy': 0.9234369, 'pp': 335.5832, 'epochTime': 1727827105}, 'leaderboard': {'id': 'ca3051', 'songHash': 'd44de2eebd64f3cfa70c024fabb042bf73a43f41', 'modeName': 'Standard', 'difficulty': 5}}, {'score': {'id': 18202861, 'baseScore': 1364040, 'modifiedScore': 1364040, 'modifiers': '', 'fullCombo': False, 'maxCombo': 678, 'missedNotes': 6, 'badCuts': 1, 'hmd': 512, 'controller': 1, 'accuracy': 0.9236989, 'pp': 337.5511, 'epochTime': 1727824695}, 'leaderboard': {'id': '11b4991', 'songHash': '09f8bee6908e3a9cd724b3db3162a5c381ecb156', 'modeName': 'Standard', 'difficulty': 9}}, {'score': {'id': 18202637, 'baseScore': 397686, 'modifiedScore': 397686, 'modifiers': '', 'fullCombo': True, 'maxCombo': 456, 'missedNotes': 0, 'badCuts': 0, 'hmd': 512, 'controller': 1, 'accuracy': 0.96461344, 'pp': 0, 'epochTime': 1727823927}, 'leaderboard': {'id': '21e2151', 'songHash': '1262e162a207aa7fbcf18f18eaf5a612a35f4139', 'modeName': 'Standard', 'difficulty': 5}}])) + +2024-10-04 10:20:02 DEBUG: Using selector: EpollSelector +`` +``` + +Please help us finish this wrapper class by implementing the get_player_scores() method. \ No newline at end of file diff --git a/docs/prompts/03-scratchpad.md b/docs/prompts/03-scratchpad.md new file mode 100644 index 0000000..ff23c79 --- /dev/null +++ b/docs/prompts/03-scratchpad.md @@ -0,0 +1,599 @@ +# New Python Class + +We are working on this new Python class: + + +```python +import json +import os +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +from clients.beatleader import client as beatleader_client +from clients.beatleader.api.player_scores import player_scores_get_compact_scores +from clients.beatleader.models.compact_score_response_response_with_metadata import CompactScoreResponseResponseWithMetadata + +logging.basicConfig( + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.DEBUG +) + +class BeatLeaderAPI: + BASE_URL = "https://api.beatleader.xyz" + + def __init__(self, cache_expiry_days: int = 1, cache_dir: Optional[str] = None): + self.client = beatleader_client.Client(base_url=self.BASE_URL) + self.cache_expiry_days = cache_expiry_days + self.CACHE_DIR = cache_dir or self._determine_cache_dir() + if not os.path.exists(self.CACHE_DIR): + os.makedirs(self.CACHE_DIR) + logging.info(f"Created cache directory: {self.CACHE_DIR}") + + def _determine_cache_dir(self) -> str: + home_cache = os.path.expanduser("~/.cache") + beatleader_cache = os.path.join(home_cache, "beatleader") + + if os.path.exists(home_cache): + if not os.path.exists(beatleader_cache): + try: + os.makedirs(beatleader_cache) + logging.info(f"Created cache directory: {beatleader_cache}") + except OSError as e: + logging.warning(f"Failed to create {beatleader_cache}: {e}") + return os.path.join(os.getcwd(), ".cache") + return beatleader_cache + else: + logging.info("~/.cache doesn't exist, using local .cache directory") + return os.path.join(os.getcwd(), ".cache") + + def _get_cache_filename(self, player_id: str) -> str: + return os.path.join(self.CACHE_DIR, f"player_{player_id}_scores.json") + + def _is_cache_valid(self, cache_file: str) -> bool: + if not os.path.exists(cache_file): + return False + file_modified_time = datetime.fromtimestamp(os.path.getmtime(cache_file)) + return datetime.now() - file_modified_time < timedelta(days=self.cache_expiry_days) + + def get_player_scores( + self, + player_id: str, + use_cache: bool = True, + count: int = 100, + sort: str = "recent", + max_pages: Optional[int] = None + ) -> Dict[str, Any]: + """ + Fetches all player scores for a given player ID, handling pagination and caching. + + :param player_id: The ScoreSaber player ID. + :param use_cache: Whether to use cached data if available. + :param limit: Number of scores per page. + :param sort: Sorting criteria. + :param max_pages: Maximum number of pages to fetch. Fetch all if None. + :return: A dictionary containing metadata and a list of player scores. + """ + cache_file = self._get_cache_filename(player_id) + + if use_cache and self._is_cache_valid(cache_file): + logging.debug(f"Using cached data for player {player_id}") + with open(cache_file, 'r') as f: + return json.load(f) + + logging.debug(f"Fetching fresh data for player {player_id}") + + all_scores = [] + page = 1 + total_items = None + + while max_pages is None or page <= max_pages: + try: + response: CompactScoreResponseResponseWithMetadata = player_scores_get_compact_scores.sync( + client=self.client, + id=player_id, + page=page, + count=count, + sort=sort + ) + + except Exception as e: + logging.error(f"Error fetching page {page} for player {player_id}: {e}") + return {"metadata": {}, "playerScores": []} + + all_scores.extend(response.data) + + if total_items is None: + total_items = response.metadata.total + logging.debug(f"Total scores to fetch: {total_items}") + + logging.debug(f"Fetched page {page}: {len(response.data)} scores") + + if len(all_scores) >= total_items: + break + + page += 1 + + result = { + 'metadata': { + 'itemsPerPage': response.metadata.items_per_page, + 'page': response.metadata.page, + 'total': response.metadata.total + }, + 'playerScores': all_scores + } + + with open(cache_file, 'w') as f: + json.dump(result, f, default=str) # default=str to handle datetime serialization + + logging.info(f"Cached scores for player {player_id} at {cache_file}") + + return result +``` + +Here is `src/clients/beatleader/api/player_scores/player_scores_get_compact_scores.py`: + +```python +from http import HTTPStatus +from typing import Any, Dict, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.compact_score_response_response_with_metadata import CompactScoreResponseResponseWithMetadata +from ...models.difficulty_status import DifficultyStatus +from ...models.leaderboard_contexts import LeaderboardContexts +from ...models.order import Order +from ...models.requirements import Requirements +from ...models.score_filter_status import ScoreFilterStatus +from ...models.scores_sort_by import ScoresSortBy +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + id: str, + *, + sort_by: Union[Unset, ScoresSortBy] = UNSET, + order: Union[Unset, Order] = UNSET, + page: Union[Unset, int] = 1, + count: Union[Unset, int] = 8, + search: Union[Unset, str] = UNSET, + diff: Union[Unset, str] = UNSET, + mode: Union[Unset, str] = UNSET, + requirements: Union[Unset, Requirements] = UNSET, + score_status: Union[Unset, ScoreFilterStatus] = UNSET, + leaderboard_context: Union[Unset, LeaderboardContexts] = UNSET, + type: Union[Unset, DifficultyStatus] = UNSET, + modifiers: Union[Unset, str] = UNSET, + stars_from: Union[Unset, float] = UNSET, + stars_to: Union[Unset, float] = UNSET, + time_from: Union[Unset, int] = UNSET, + time_to: Union[Unset, int] = UNSET, + event_id: Union[Unset, int] = UNSET, +) -> Dict[str, Any]: + params: Dict[str, Any] = {} + + json_sort_by: Union[Unset, str] = UNSET + if not isinstance(sort_by, Unset): + json_sort_by = sort_by.value + + params["sortBy"] = json_sort_by + + json_order: Union[Unset, str] = UNSET + if not isinstance(order, Unset): + json_order = order.value + + params["order"] = json_order + + params["page"] = page + + params["count"] = count + + params["search"] = search + + params["diff"] = diff + + params["mode"] = mode + + json_requirements: Union[Unset, str] = UNSET + if not isinstance(requirements, Unset): + json_requirements = requirements.value + + params["requirements"] = json_requirements + + json_score_status: Union[Unset, str] = UNSET + if not isinstance(score_status, Unset): + json_score_status = score_status.value + + params["scoreStatus"] = json_score_status + + json_leaderboard_context: Union[Unset, str] = UNSET + if not isinstance(leaderboard_context, Unset): + json_leaderboard_context = leaderboard_context.value + + params["leaderboardContext"] = json_leaderboard_context + + json_type: Union[Unset, str] = UNSET + if not isinstance(type, Unset): + json_type = type.value + + params["type"] = json_type + + params["modifiers"] = modifiers + + params["stars_from"] = stars_from + + params["stars_to"] = stars_to + + params["time_from"] = time_from + + params["time_to"] = time_to + + params["eventId"] = event_id + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: Dict[str, Any] = { + "method": "get", + "url": f"/player/{id}/scores/compact", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, CompactScoreResponseResponseWithMetadata]]: + if response.status_code == HTTPStatus.OK: + response_200 = CompactScoreResponseResponseWithMetadata.from_dict(response.json()) + + return response_200 + if response.status_code == HTTPStatus.BAD_REQUEST: + response_400 = cast(Any, None) + return response_400 + if response.status_code == HTTPStatus.NOT_FOUND: + response_404 = cast(Any, None) + return response_404 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, CompactScoreResponseResponseWithMetadata]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: Union[AuthenticatedClient, Client], + sort_by: Union[Unset, ScoresSortBy] = UNSET, + order: Union[Unset, Order] = UNSET, + page: Union[Unset, int] = 1, + count: Union[Unset, int] = 8, + search: Union[Unset, str] = UNSET, + diff: Union[Unset, str] = UNSET, + mode: Union[Unset, str] = UNSET, + requirements: Union[Unset, Requirements] = UNSET, + score_status: Union[Unset, ScoreFilterStatus] = UNSET, + leaderboard_context: Union[Unset, LeaderboardContexts] = UNSET, + type: Union[Unset, DifficultyStatus] = UNSET, + modifiers: Union[Unset, str] = UNSET, + stars_from: Union[Unset, float] = UNSET, + stars_to: Union[Unset, float] = UNSET, + time_from: Union[Unset, int] = UNSET, + time_to: Union[Unset, int] = UNSET, + event_id: Union[Unset, int] = UNSET, +) -> Response[Union[Any, CompactScoreResponseResponseWithMetadata]]: + """Retrieve player's scores in a compact form + + Fetches a paginated list of scores for a specified player ID. Returns less info to save bandwith or + processing time + + Args: + id (str): + sort_by (Union[Unset, ScoresSortBy]): + order (Union[Unset, Order]): Represents the order in which values will be sorted. + page (Union[Unset, int]): Default: 1. + count (Union[Unset, int]): Default: 8. + search (Union[Unset, str]): + diff (Union[Unset, str]): + mode (Union[Unset, str]): + requirements (Union[Unset, Requirements]): + score_status (Union[Unset, ScoreFilterStatus]): + leaderboard_context (Union[Unset, LeaderboardContexts]): + type (Union[Unset, DifficultyStatus]): Represents the difficulty status of a map. + modifiers (Union[Unset, str]): + stars_from (Union[Unset, float]): + stars_to (Union[Unset, float]): + time_from (Union[Unset, int]): + time_to (Union[Unset, int]): + event_id (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, CompactScoreResponseResponseWithMetadata]] + """ + + kwargs = _get_kwargs( + id=id, + sort_by=sort_by, + order=order, + page=page, + count=count, + search=search, + diff=diff, + mode=mode, + requirements=requirements, + score_status=score_status, + leaderboard_context=leaderboard_context, + type=type, + modifiers=modifiers, + stars_from=stars_from, + stars_to=stars_to, + time_from=time_from, + time_to=time_to, + event_id=event_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + id: str, + *, + client: Union[AuthenticatedClient, Client], + sort_by: Union[Unset, ScoresSortBy] = UNSET, + order: Union[Unset, Order] = UNSET, + page: Union[Unset, int] = 1, + count: Union[Unset, int] = 8, + search: Union[Unset, str] = UNSET, + diff: Union[Unset, str] = UNSET, + mode: Union[Unset, str] = UNSET, + requirements: Union[Unset, Requirements] = UNSET, + score_status: Union[Unset, ScoreFilterStatus] = UNSET, + leaderboard_context: Union[Unset, LeaderboardContexts] = UNSET, + type: Union[Unset, DifficultyStatus] = UNSET, + modifiers: Union[Unset, str] = UNSET, + stars_from: Union[Unset, float] = UNSET, + stars_to: Union[Unset, float] = UNSET, + time_from: Union[Unset, int] = UNSET, + time_to: Union[Unset, int] = UNSET, + event_id: Union[Unset, int] = UNSET, +) -> Optional[Union[Any, CompactScoreResponseResponseWithMetadata]]: + """Retrieve player's scores in a compact form + + Fetches a paginated list of scores for a specified player ID. Returns less info to save bandwith or + processing time + + Args: + id (str): + sort_by (Union[Unset, ScoresSortBy]): + order (Union[Unset, Order]): Represents the order in which values will be sorted. + page (Union[Unset, int]): Default: 1. + count (Union[Unset, int]): Default: 8. + search (Union[Unset, str]): + diff (Union[Unset, str]): + mode (Union[Unset, str]): + requirements (Union[Unset, Requirements]): + score_status (Union[Unset, ScoreFilterStatus]): + leaderboard_context (Union[Unset, LeaderboardContexts]): + type (Union[Unset, DifficultyStatus]): Represents the difficulty status of a map. + modifiers (Union[Unset, str]): + stars_from (Union[Unset, float]): + stars_to (Union[Unset, float]): + time_from (Union[Unset, int]): + time_to (Union[Unset, int]): + event_id (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, CompactScoreResponseResponseWithMetadata] + """ + + return sync_detailed( + id=id, + client=client, + sort_by=sort_by, + order=order, + page=page, + count=count, + search=search, + diff=diff, + mode=mode, + requirements=requirements, + score_status=score_status, + leaderboard_context=leaderboard_context, + type=type, + modifiers=modifiers, + stars_from=stars_from, + stars_to=stars_to, + time_from=time_from, + time_to=time_to, + event_id=event_id, + ).parsed + + +async def asyncio_detailed( + id: str, + *, + client: Union[AuthenticatedClient, Client], + sort_by: Union[Unset, ScoresSortBy] = UNSET, + order: Union[Unset, Order] = UNSET, + page: Union[Unset, int] = 1, + count: Union[Unset, int] = 8, + search: Union[Unset, str] = UNSET, + diff: Union[Unset, str] = UNSET, + mode: Union[Unset, str] = UNSET, + requirements: Union[Unset, Requirements] = UNSET, + score_status: Union[Unset, ScoreFilterStatus] = UNSET, + leaderboard_context: Union[Unset, LeaderboardContexts] = UNSET, + type: Union[Unset, DifficultyStatus] = UNSET, + modifiers: Union[Unset, str] = UNSET, + stars_from: Union[Unset, float] = UNSET, + stars_to: Union[Unset, float] = UNSET, + time_from: Union[Unset, int] = UNSET, + time_to: Union[Unset, int] = UNSET, + event_id: Union[Unset, int] = UNSET, +) -> Response[Union[Any, CompactScoreResponseResponseWithMetadata]]: + """Retrieve player's scores in a compact form + + Fetches a paginated list of scores for a specified player ID. Returns less info to save bandwith or + processing time + + Args: + id (str): + sort_by (Union[Unset, ScoresSortBy]): + order (Union[Unset, Order]): Represents the order in which values will be sorted. + page (Union[Unset, int]): Default: 1. + count (Union[Unset, int]): Default: 8. + search (Union[Unset, str]): + diff (Union[Unset, str]): + mode (Union[Unset, str]): + requirements (Union[Unset, Requirements]): + score_status (Union[Unset, ScoreFilterStatus]): + leaderboard_context (Union[Unset, LeaderboardContexts]): + type (Union[Unset, DifficultyStatus]): Represents the difficulty status of a map. + modifiers (Union[Unset, str]): + stars_from (Union[Unset, float]): + stars_to (Union[Unset, float]): + time_from (Union[Unset, int]): + time_to (Union[Unset, int]): + event_id (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, CompactScoreResponseResponseWithMetadata]] + """ + + kwargs = _get_kwargs( + id=id, + sort_by=sort_by, + order=order, + page=page, + count=count, + search=search, + diff=diff, + mode=mode, + requirements=requirements, + score_status=score_status, + leaderboard_context=leaderboard_context, + type=type, + modifiers=modifiers, + stars_from=stars_from, + stars_to=stars_to, + time_from=time_from, + time_to=time_to, + event_id=event_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + id: str, + *, + client: Union[AuthenticatedClient, Client], + sort_by: Union[Unset, ScoresSortBy] = UNSET, + order: Union[Unset, Order] = UNSET, + page: Union[Unset, int] = 1, + count: Union[Unset, int] = 8, + search: Union[Unset, str] = UNSET, + diff: Union[Unset, str] = UNSET, + mode: Union[Unset, str] = UNSET, + requirements: Union[Unset, Requirements] = UNSET, + score_status: Union[Unset, ScoreFilterStatus] = UNSET, + leaderboard_context: Union[Unset, LeaderboardContexts] = UNSET, + type: Union[Unset, DifficultyStatus] = UNSET, + modifiers: Union[Unset, str] = UNSET, + stars_from: Union[Unset, float] = UNSET, + stars_to: Union[Unset, float] = UNSET, + time_from: Union[Unset, int] = UNSET, + time_to: Union[Unset, int] = UNSET, + event_id: Union[Unset, int] = UNSET, +) -> Optional[Union[Any, CompactScoreResponseResponseWithMetadata]]: + """Retrieve player's scores in a compact form + + Fetches a paginated list of scores for a specified player ID. Returns less info to save bandwith or + processing time + + Args: + id (str): + sort_by (Union[Unset, ScoresSortBy]): + order (Union[Unset, Order]): Represents the order in which values will be sorted. + page (Union[Unset, int]): Default: 1. + count (Union[Unset, int]): Default: 8. + search (Union[Unset, str]): + diff (Union[Unset, str]): + mode (Union[Unset, str]): + requirements (Union[Unset, Requirements]): + score_status (Union[Unset, ScoreFilterStatus]): + leaderboard_context (Union[Unset, LeaderboardContexts]): + type (Union[Unset, DifficultyStatus]): Represents the difficulty status of a map. + modifiers (Union[Unset, str]): + stars_from (Union[Unset, float]): + stars_to (Union[Unset, float]): + time_from (Union[Unset, int]): + time_to (Union[Unset, int]): + event_id (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, CompactScoreResponseResponseWithMetadata] + """ + + return ( + await asyncio_detailed( + id=id, + client=client, + sort_by=sort_by, + order=order, + page=page, + count=count, + search=search, + diff=diff, + mode=mode, + requirements=requirements, + score_status=score_status, + leaderboard_context=leaderboard_context, + type=type, + modifiers=modifiers, + stars_from=stars_from, + stars_to=stars_to, + time_from=time_from, + time_to=time_to, + event_id=event_id, + ) + ).parsed +``` + +Please review get_player_scores(), we wonder if the sort option is done correctly. diff --git a/pyproject.toml b/pyproject.toml index b5d819e..f659975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ Homepage = "https://git.satstack.dev/blee/beatsaber-playlist-tool" #replay_all_by_acc = "saberlist.scoresaber:replay_all_by_acc" #leaderboard_songs_by_stars = "saberlist.scoresaber:leaderboard_songs" #saberlist_replay_bl = "saberlist.beatleader:saberlist_replay_bl" -settle_old_scores_ss = "saberlist.make:create_playlist" +saberlist = "saberlist.make:saberlist" [tool.pytest.ini_options] pythonpath = ["src"] \ No newline at end of file diff --git a/src/clients/beatleader/api/player_scores/player_scores_get_compact_scores.py b/src/clients/beatleader/api/player_scores/player_scores_get_compact_scores.py index 5ed4627..227c808 100644 --- a/src/clients/beatleader/api/player_scores/player_scores_get_compact_scores.py +++ b/src/clients/beatleader/api/player_scores/player_scores_get_compact_scores.py @@ -111,7 +111,7 @@ def _parse_response( *, client: Union[AuthenticatedClient, Client], response: httpx.Response ) -> Optional[Union[Any, CompactScoreResponseResponseWithMetadata]]: if response.status_code == HTTPStatus.OK: - response_200 = CompactScoreResponseResponseWithMetadata.from_dict(response.text) + response_200 = CompactScoreResponseResponseWithMetadata.from_dict(response.json()) return response_200 if response.status_code == HTTPStatus.BAD_REQUEST: diff --git a/src/clients/beatleader/api/player_scores/player_scores_get_scores.py b/src/clients/beatleader/api/player_scores/player_scores_get_scores.py index 8609209..087e841 100644 --- a/src/clients/beatleader/api/player_scores/player_scores_get_scores.py +++ b/src/clients/beatleader/api/player_scores/player_scores_get_scores.py @@ -111,7 +111,7 @@ def _parse_response( *, client: Union[AuthenticatedClient, Client], response: httpx.Response ) -> Optional[Union[Any, ScoreResponseWithMyScoreResponseWithMetadata]]: if response.status_code == HTTPStatus.OK: - response_200 = ScoreResponseWithMyScoreResponseWithMetadata.from_dict(response.text) + response_200 = ScoreResponseWithMyScoreResponseWithMetadata.from_dict(response.json()) return response_200 if response.status_code == HTTPStatus.BAD_REQUEST: diff --git a/src/helpers/BeatLeaderAPI.py b/src/helpers/BeatLeaderAPI.py new file mode 100644 index 0000000..11e7cf9 --- /dev/null +++ b/src/helpers/BeatLeaderAPI.py @@ -0,0 +1,130 @@ +import json +import os +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +from clients.beatleader import client as beatleader_client +from clients.beatleader.api.player_scores import player_scores_get_compact_scores +from clients.beatleader.models.compact_score_response_response_with_metadata import CompactScoreResponseResponseWithMetadata +from clients.beatleader.models.scores_sort_by import ScoresSortBy +from clients.beatleader.models.order import Order + +logging.basicConfig( + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.DEBUG +) + +class BeatLeaderAPI: + BASE_URL = "https://api.beatleader.xyz" + + def __init__(self, cache_expiry_days: int = 1, cache_dir: Optional[str] = None): + self.client = beatleader_client.Client(base_url=self.BASE_URL) + self.cache_expiry_days = cache_expiry_days + self.CACHE_DIR = cache_dir or self._determine_cache_dir() + if not os.path.exists(self.CACHE_DIR): + os.makedirs(self.CACHE_DIR) + logging.info(f"Created cache directory: {self.CACHE_DIR}") + + def _determine_cache_dir(self) -> str: + home_cache = os.path.expanduser("~/.cache") + beatleader_cache = os.path.join(home_cache, "beatleader") + + if os.path.exists(home_cache): + if not os.path.exists(beatleader_cache): + try: + os.makedirs(beatleader_cache) + logging.info(f"Created cache directory: {beatleader_cache}") + except OSError as e: + logging.warning(f"Failed to create {beatleader_cache}: {e}") + return os.path.join(os.getcwd(), ".cache") + return beatleader_cache + else: + logging.info("~/.cache doesn't exist, using local .cache directory") + return os.path.join(os.getcwd(), ".cache") + + def _get_cache_filename(self, player_id: str) -> str: + return os.path.join(self.CACHE_DIR, f"player_{player_id}_scores.json") + + def _is_cache_valid(self, cache_file: str) -> bool: + if not os.path.exists(cache_file): + return False + file_modified_time = datetime.fromtimestamp(os.path.getmtime(cache_file)) + return datetime.now() - file_modified_time < timedelta(days=self.cache_expiry_days) + + def get_player_scores( + self, + player_id: str, + use_cache: bool = True, + count: int = 100, + sort_by: ScoresSortBy = ScoresSortBy.DATE, + order: Order = Order.DESC, + max_pages: Optional[int] = None + ) -> Dict[str, Any]: + """ + Fetches all player scores for a given player ID, handling pagination and caching. + + :param player_id: The ScoreSaber player ID. + :param use_cache: Whether to use cached data if available. + :param limit: Number of scores per page. + :param sort: Sorting criteria. + :param max_pages: Maximum number of pages to fetch. Fetch all if None. + :return: A dictionary containing metadata and a list of player scores. + """ + cache_file = self._get_cache_filename(player_id) + + if use_cache and self._is_cache_valid(cache_file): + logging.debug(f"Using cached data for player {player_id}") + with open(cache_file, 'r') as f: + return json.load(f) + + logging.debug(f"Fetching fresh data for player {player_id}") + + all_scores = [] + page = 1 + total_items = None + + while max_pages is None or page <= max_pages: + try: + response: CompactScoreResponseResponseWithMetadata = player_scores_get_compact_scores.sync( + client=self.client, + id=player_id, + page=page, + count=count, + sort_by=sort_by, + order=order + ) + + except Exception as e: + logging.error(f"Error fetching page {page} for player {player_id}: {e}") + return {"metadata": {}, "playerScores": []} + + all_scores.extend(response.data) + + if total_items is None: + total_items = response.metadata.total + logging.debug(f"Total scores to fetch: {total_items}") + + logging.debug(f"Fetched page {page}: {len(response.data)} scores") + + if len(all_scores) >= total_items: + break + + page += 1 + + result = { + 'metadata': { + 'itemsPerPage': response.metadata.items_per_page, + 'page': response.metadata.page, + 'total': response.metadata.total + }, + 'playerScores': all_scores + } + + with open(cache_file, 'w') as f: + json.dump(result, f, default=str) # default=str to handle datetime serialization + + logging.info(f"Cached scores for player {player_id} at {cache_file}") + + return result \ No newline at end of file diff --git a/src/helpers/ScoreSaberAPI.py b/src/helpers/ScoreSaberAPI.py index 694252f..d7296d0 100644 --- a/src/helpers/ScoreSaberAPI.py +++ b/src/helpers/ScoreSaberAPI.py @@ -77,7 +77,6 @@ class ScoreSaberAPI: return json.load(f) logging.debug(f"Fetching fresh data for player {player_id}") - url_player_scores = f"/api/player/{player_id}/scores" all_scores = [] page = 1 @@ -94,7 +93,7 @@ class ScoreSaberAPI: ) except Exception as e: logging.error(f"Error fetching page {page} for player {player_id}: {e}") - break + return {"metadata": {}, "playerScores": []} all_scores.extend([score.dict() for score in response.player_scores]) diff --git a/src/helpers/SimpleBeatLeaderAPI.py b/src/helpers/SimpleBeatLeaderAPI.py new file mode 100644 index 0000000..95f57d4 --- /dev/null +++ b/src/helpers/SimpleBeatLeaderAPI.py @@ -0,0 +1,133 @@ +from datetime import datetime, timedelta +import base64 +import json +import os +import random +import requests +import time + +import logging +logging.basicConfig( + format='%(asctime)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.DEBUG +) + +class SimpleBeatLeaderAPI: + BASE_URL = "https://api.beatleader.xyz" + + def __init__(self, cache_expiry_days=1): + self.session = requests.Session() + self.cache_expiry_days = cache_expiry_days + self.CACHE_DIR = self._determine_cache_dir() + if not os.path.exists(self.CACHE_DIR): + os.makedirs(self.CACHE_DIR) + + def _determine_cache_dir(self): + home_cache = os.path.expanduser("~/.cache") + saberlist_cache = os.path.join(home_cache, "saberlist") + + if os.path.exists(home_cache): + if not os.path.exists(saberlist_cache): + try: + os.makedirs(saberlist_cache) + logging.info(f"Created cache directory: {saberlist_cache}") + except OSError as e: + logging.warning(f"Failed to create {saberlist_cache}: {e}") + return os.path.join(os.getcwd(), ".cache") + return saberlist_cache + else: + logging.info("~/.cache doesn't exist, using local .cache directory") + return os.path.join(os.getcwd(), ".cache") + + def _get_cache_filename(self, player_id): + return os.path.join(self.CACHE_DIR, f"player_{player_id}_scores.json") + + def _is_cache_valid(self, cache_file): + if not os.path.exists(cache_file): + return False + file_modified_time = datetime.fromtimestamp(os.path.getmtime(cache_file)) + return datetime.now() - file_modified_time < timedelta(days=self.cache_expiry_days) + + def get_player_scores(self, player_id, use_cache=True, page_size=100, max_pages=None): + cache_file = self._get_cache_filename(player_id) + + if use_cache and self._is_cache_valid(cache_file): + logging.debug(f"Using cached data for player {player_id}") + with open(cache_file, 'r') as f: + return json.load(f) + + logging.debug(f"Fetching fresh data for player {player_id}") + url = f"{self.BASE_URL}/player/{player_id}/scores" + + all_scores = [] + page = 1 + total_items = None + + while max_pages is None or page <= max_pages: + params = { + "page": page, + "count": page_size + } + response = self.session.get(url, params=params) + response.raise_for_status() + data = response.json() + + all_scores.extend(data['data']) + + if total_items is None: + total_items = data['metadata']['total'] + + if len(all_scores) >= total_items: + break + + page += 1 + time.sleep(1) # Add a small delay to avoid rate limiting + + result = { + 'metadata': { + 'total': total_items, + 'itemsPerPage': page_size, + 'page': page + }, + 'data': all_scores + } + + with open(cache_file, 'w') as f: + json.dump(result, f) + + return result + + def clear_cache(self, player_id=None): + if player_id: + cache_file = self._get_cache_filename(player_id) + if os.path.exists(cache_file): + os.remove(cache_file) + logging.debug(f"Cleared cache for player {player_id}") + else: + for file in os.listdir(self.CACHE_DIR): + os.remove(os.path.join(self.CACHE_DIR, file)) + logging.debug("Cleared all cache") + + def get_cache_dir(self): + return self.CACHE_DIR + + + def get_player_info(self, player_id): + """ + Retrieve information for a specific player. + + :param player_id: ID of the player + :return: Dictionary containing player information + """ + url = f"{self.BASE_URL}/player/{player_id}" + + try: + response = self.session.get(url) + response.raise_for_status() + player_data = response.json() + + return player_data + except requests.exceptions.RequestException as e: + logging.error(f"Error fetching player info for ID {player_id}: {e}") + return None \ No newline at end of file diff --git a/src/saberlist/beatleader.py b/src/saberlist/beatleader.py deleted file mode 100644 index 2783d9a..0000000 --- a/src/saberlist/beatleader.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -from collections import defaultdict -from datetime import datetime -from saberlist.BeatLeaderAPI import BeatLeaderAPI -from archive.PlaylistBuilder import PlaylistBuilder -import json -import os - -import logging -logging.basicConfig( - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', - level=logging.DEBUG -) - -def prompt_for_player_id(default_id='76561199407393962'): - prompt = f"Enter player ID (press Enter for default '{default_id}'): " - user_input = input(prompt).strip() - return user_input if user_input else default_id - -def build_difficulty_based_playlist(api, player_id, history, playlist_name, song_count=5): - scores_data = api.get_player_scores(player_id, use_cache=True) - all_scores = scores_data['data'] - all_scores.sort(key=lambda x: x['timepost']) - - difficulty_groups = defaultdict(list) - no_stars_group = [] - for score in all_scores: - stars = score['leaderboard']['difficulty'].get('stars') - song_id = score['leaderboard']['song']['id'] - difficulty_name = score['leaderboard']['difficulty']['difficultyName'] - - # Check if this song:difficulty combination is in history - if song_id not in history or difficulty_name not in history[song_id]: - if stars is None or stars == 0: - no_stars_group.append(score) - elif 0 < stars <= 3: - difficulty_groups[0].append(score) - elif 4 <= stars < 6: - difficulty_groups[1].append(score) - elif 6 <= stars < 7: - difficulty_groups[2].append(score) - elif stars >= 7: - difficulty_groups[3].append(score) - - playlist_scores = [] - for difficulty, count in [(0, song_count), (1, song_count), (2, song_count), (3, song_count)]: - unique_songs = {} - for score in difficulty_groups[difficulty]: - song_id = score['leaderboard']['song']['id'] - if song_id not in unique_songs or score['timepost'] < unique_songs[song_id]['timepost']: - unique_songs[song_id] = score - - playlist_scores.extend(sorted(unique_songs.values(), key=lambda x: x['timepost'])[:count]) - - # Update history - for score in playlist_scores: - song_id = score['leaderboard']['song']['id'] - difficulty_name = score['leaderboard']['difficulty']['difficultyName'] - if song_id not in history: - history[song_id] = [] - history[song_id].append(difficulty_name) - - # Prepare the custom playlist - custom_playlist = [] - for score in playlist_scores: - custom_playlist.append({ - 'song': score['leaderboard']['song'], - 'difficulty': score['leaderboard']['difficulty'] - }) - - playlist_builder = PlaylistBuilder() - playlist_file, used_cover = playlist_builder.create_player_playlist_with_random_cover( - playlist_scores, - playlist_name, - "SaberList Tool", - used_covers=set(history['used_covers']) - ) - - if used_cover: - history['used_covers'].append(used_cover) - - return playlist_file, playlist_scores, used_cover - -def saberlist_replay_bl(): - api = BeatLeaderAPI() - player_id = prompt_for_player_id() - player_info = api.get_player_info(player_id) - - if player_info: - logging.info(f"Fetching score history for player: {player_info.get('name', 'N/A')}") - - history = load_history() - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - playlist_name = f"star_ladder-{timestamp}" - - playlist_file, playlist_scores, used_cover = build_difficulty_based_playlist(api, player_id, history, playlist_name, song_count=10) - save_history(history) - - print(f"Playlist created: {playlist_file}") - if used_cover: - print(f"Cover image used: {used_cover}") - print("Playlist contents:") - for i, score in enumerate(playlist_scores, 1): - song = score['leaderboard']['song'] - difficulty = score['leaderboard']['difficulty'] - print(f"{i}. {song['name']} by {song['author']} (Mapper: {song['mapper']}) - {difficulty['stars']:.2f} stars - Last played: {datetime.fromtimestamp(score['timepost'])}") - -if __name__ == "__main__": - saberlist_replay_bl() -""" \ No newline at end of file diff --git a/src/saberlist/make.py b/src/saberlist/make.py index 982ee70..96cd19d 100644 --- a/src/saberlist/make.py +++ b/src/saberlist/make.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv load_dotenv() LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper() HISTORY_FILE = os.environ.get('HISTORY_FILE', "playlist_history.json") -CACHE_EXPIRY_DAYS = int(os.environ.get('CACHE_EXPIRY_DAYS', 2)) +CACHE_EXPIRY_DAYS = int(os.environ.get('CACHE_EXPIRY_DAYS', 7)) import logging logging.basicConfig( @@ -20,6 +20,7 @@ logging.basicConfig( from helpers.PlaylistBuilder import PlaylistBuilder from helpers.ScoreSaberAPI import ScoreSaberAPI +from helpers.BeatLeaderAPI import BeatLeaderAPI def load_history() -> Dict[str, Any]: """ @@ -43,7 +44,7 @@ def save_history(history: Dict[str, Any]) -> None: def prompt_for_player_id(default_id: str = '76561199407393962') -> str: """ - Prompt the user to enter a ScoreSaber player ID. + Prompt the user to enter a ScoreSaber or BeatLeader player ID. Uses a default ID if the user presses Enter without input. :param default_id: The default player ID to use. @@ -63,7 +64,7 @@ def format_time_ago(time_difference: timedelta) -> str: days = time_difference.days if days < 7: - return f"{days} days(s)" + return f"{days} day(s)" elif days < 30: weeks = days // 7 return f"{weeks} week(s)" @@ -74,171 +75,138 @@ def format_time_ago(time_difference: timedelta) -> str: years = days // 365 return f"{years} year(s)" -def normalize_difficulty(difficulty_name): - # ScoreSaber difficulty naming convention - difficulty_map = { +def normalize_difficulty_name(difficulty_name): + difficulty_names = { + # ScoreSaber '_ExpertPlus_SoloStandard': 'expertplus', '_Expert_SoloStandard': 'expert', '_Hard_SoloStandard': 'hard', '_Normal_SoloStandard': 'normal', - '_Easy_SoloStandard': 'easy' + '_Easy_SoloStandard': 'easy', + # BeatLeader + 1: 'easy', + 3: 'normal', + 5: 'hard', + 7: 'expert', + 9: 'expertplus', } - return difficulty_map.get(difficulty_name, difficulty_name.lower().replace('_solostandard', '')) + # Return the mapped value or the original name if there is no mapping + return difficulty_names.get(difficulty_name, difficulty_name) -def playlist_strategy_oldscores( - api: ScoreSaberAPI, - song_count: int = 10 +def playlist_strategy_beatleader_oldscores( + api: BeatLeaderAPI, + song_count: int = 40 ) -> List[Dict[str, Any]]: - """Build and format a list of songs based on old scores, avoiding reusing the same song+difficulty.""" + """ + Build and format a list of songs based on old scores from BeatLeader, + avoiding reusing the same song+difficulty. + The playlist will consist of song hashes and their corresponding difficulties. + """ + player_id = prompt_for_player_id() history = load_history() - history.setdefault('oldscores', {}) + history.setdefault('beatleader_oldscores', {}) - scores_data = api.get_player_scores(player_id, use_cache=True) + 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}.") + 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}.") + logging.debug(f"Found {len(all_scores)} scores for player ID {player_id} on BeatLeader.") - # Sort scores by timeSet in ascending order (oldest first) - all_scores.sort(key=lambda x: x['score'].get('timeSet', '')) + # Sort scores by epochTime in ascending order (oldest first) + all_scores.sort(key=lambda x: x.get('score', {}).get('epochTime', 0)) - difficulty_groups = defaultdict(list) - no_stars_group = [] + playlist_data = [] current_time = datetime.now(timezone.utc) - for score in all_scores: - leaderboard = score.get('leaderboard', {}) - stars = leaderboard.get('stars', 0) - song_id = leaderboard.get('songHash') - difficulty_raw = leaderboard.get('difficulty', {}).get('difficultyRaw', '') + for score_entry in all_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', {}) - 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 + song_hash = leaderboard.get('songHash') + difficulty_raw = int(leaderboard.get('difficulty', '')) + game_mode = leaderboard.get('modeName', 'Standard') + epoch_time = score.get('epochTime') + + if not song_hash or not difficulty_raw or not epoch_time: + 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_oldscores'] and difficulty in history['beatleader_oldscores'][song_hash]: + logging.debug(f"Skipping song {song_hash} with difficulty {difficulty} as it's in history.") + continue # Skip if already used # 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}") + time_set = datetime.fromtimestamp(epoch_time, tz=timezone.utc) + except (ValueError, OSError) as e: + logging.error(f"Invalid epochTime for score ID {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(difficulty_raw) - - # Check history to avoid reusing song+difficulty - if song_id in history['oldscores'] and difficulty in history['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 + # Format the song data for PlaylistBuilder song_dict = { - 'hash': song_id, - 'songName': leaderboard.get('songName', 'Unknown'), + 'hash': song_hash, 'difficulties': [ { 'name': difficulty, - 'characteristic': leaderboard.get('difficulty', {}).get('gameMode', 'Standard') + 'characteristic': game_mode } ] } - # Group songs based on stars - if stars is None or stars == 0: - no_stars_group.append(song_dict) - elif 0 < stars <= 3: - difficulty_groups[0].append(song_dict) - elif 4 <= stars < 6: - difficulty_groups[1].append(song_dict) - elif 6 <= stars < 7: - difficulty_groups[2].append(song_dict) - elif stars >= 7: - difficulty_groups[3].append(song_dict) + # Add the song to the playlist + playlist_data.append(song_dict) + logging.debug(f"Selected song for playlist: Hash={song_hash}, Difficulty={difficulty}. Last played {time_ago} ago.") - playlist_data = [] + # Update history + history['beatleader_oldscores'].setdefault(song_hash, []).append(difficulty) - # Define the order and counts for each difficulty group - for difficulty_level, count in [(0, song_count), (1, song_count), (2, song_count), (3, song_count)]: - group = difficulty_groups.get(difficulty_level, []) - if not group: - logging.debug(f"No songs found for difficulty level {difficulty_level}.") - continue - # Sort by difficulty name to ensure consistent ordering - sorted_group = sorted(group, key=lambda x: x['difficulties'][0]['name']) - logging.debug(f"Sorted group for difficulty {difficulty_level}: {[song['songName'] for song in sorted_group]}") - # Select unique songs up to the desired count - unique_songs = {} - for song in sorted_group: - song_id = song['hash'] - if song_id not in unique_songs: - unique_songs[song_id] = song - logging.debug(f"Selected song for playlist: {song['songName']} ({song['difficulties'][0]['name']})") - if len(unique_songs) >= count: - break - playlist_data.extend(unique_songs.values()) - logging.debug(f"Added {len(unique_songs)} songs from difficulty level {difficulty_level} to playlist.") - - # Add no-stars songs separately - if no_stars_group: - unique_no_stars_songs = {} - sorted_no_stars = sorted(no_stars_group, key=lambda x: x['difficulties'][0]['name']) # Adjust sorting as needed - logging.debug(f"Sorted no_stars_group: {[song['songName'] for song in sorted_no_stars]}") - for song in sorted_no_stars: - song_id = song['hash'] - if song_id not in unique_no_stars_songs: - unique_no_stars_songs[song_id] = song - logging.debug(f"Selected no-star song for playlist: {song['songName']} ({song['difficulties'][0]['name']})") - if len(unique_no_stars_songs) >= 5: # Limit to 5 no-stars songs - break - playlist_data.extend(unique_no_stars_songs.values()) - logging.debug(f"Added {len(unique_no_stars_songs)} no-star songs to playlist.") - - # Log if no songs were added + # Log the final playlist if not playlist_data: - logging.info("No new songs found to add to the playlist based on history.") + logging.info("No new songs found to add to the playlist based on history for BeatLeader.") else: - # Log details of each song added to the playlist for song in playlist_data: - song_name = song['songName'] - mapper = "Unknown" # Since mapper info was used earlier but not included in the formatted data - # To include mapper, you may need to adjust the formatting in playlist_strategy_oldscores() - # For now, it's omitted or you need to include it differently + song_hash = song['hash'] difficulty = song['difficulties'][0]['name'] - logging.info(f"Song added: {song_name} ({difficulty}), mapped by {mapper}. Last played {time_ago} ago.") - logging.info(f"Total songs added to playlist: {len(playlist_data)}") + logging.info(f"Song added: Hash={song_hash}, Difficulty={difficulty}.") + logging.info(f"Total songs added to playlist from BeatLeader: {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['oldscores'].setdefault(song_id, []).append(difficulty_name) save_history(history) return playlist_data -def create_playlist(strategy='oldscores') -> None: +def saberlist(strategy='beatleader_oldscores') -> None: """ Generate a playlist of songs from a range of difficulties, all with scores previously set a long time ago. The range of difficulties ensures that the first few songs are good for warming up. Avoids reusing the same song+difficulty in a playlist based on history. """ - api = ScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS) + if strategy == 'scoresaber_oldscores': + api = ScoreSaberAPI(cache_expiry_days=CACHE_EXPIRY_DAYS) + elif strategy == 'beatleader_oldscores': + api = BeatLeaderAPI(cache_expiry_days=CACHE_EXPIRY_DAYS) + else: + logging.error(f"Unknown strategy '{strategy}'") + return - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now().strftime("%y%m%d_%H%M") playlist_name = f"{strategy}-{timestamp}" - if strategy == 'oldscores': - playlist_data = playlist_strategy_oldscores(api, song_count=10) + if strategy == 'scoresaber_oldscores': + playlist_data = playlist_strategy_scoresaber_oldscores(api) + elif strategy == 'beatleader_oldscores': + playlist_data = playlist_strategy_beatleader_oldscores(api, song_count=40) if not playlist_data: logging.info("No new scores found to add to the playlist.") @@ -249,3 +217,20 @@ def create_playlist(strategy='oldscores') -> None: playlist_title=playlist_name, playlist_author="SaberList Tool" ) + +""" +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Generate a playlist based on player scores.") + parser.add_argument( + '--strategy', + type=str, + default='beatleader_oldscores', + choices=['scoresaber_oldscores', 'beatleader_oldscores'], + help='Strategy to use for building the playlist.' + ) + args = parser.parse_args() + + saberlist(strategy=args.strategy) +""" \ No newline at end of file diff --git a/tests/playlist_builder.py b/tests/playlist_builder.py index a739b08..499808a 100644 --- a/tests/playlist_builder.py +++ b/tests/playlist_builder.py @@ -4,7 +4,7 @@ import os import json from pathlib import Path from unittest import mock -from saberlist.PlaylistBuilder import PlaylistBuilder +from helpers.PlaylistBuilder import PlaylistBuilder @pytest.fixture def temp_environment(tmp_path):