Rewrite BeatLeader class and add an oldscore strategy for it.

This commit is contained in:
Brian Lee 2024-10-04 22:01:56 -07:00
parent 76e796eb16
commit 2d87cdd59e
19 changed files with 2026 additions and 665 deletions

View File

@ -2,6 +2,12 @@
SaberList is a tool for generating custom Beat Saber playlists based on a player's performance data from Beat Leader. 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 ## Features
- Fetches player scores from Beat Leader API - Fetches player scores from Beat Leader API

View File

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

11
docs/Python.md Normal file
View File

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

View File

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

View File

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

View File

@ -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'
```

216
docs/prompts/01-template.md Normal file
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -39,7 +39,7 @@ Homepage = "https://git.satstack.dev/blee/beatsaber-playlist-tool"
#replay_all_by_acc = "saberlist.scoresaber:replay_all_by_acc" #replay_all_by_acc = "saberlist.scoresaber:replay_all_by_acc"
#leaderboard_songs_by_stars = "saberlist.scoresaber:leaderboard_songs" #leaderboard_songs_by_stars = "saberlist.scoresaber:leaderboard_songs"
#saberlist_replay_bl = "saberlist.beatleader:saberlist_replay_bl" #saberlist_replay_bl = "saberlist.beatleader:saberlist_replay_bl"
settle_old_scores_ss = "saberlist.make:create_playlist" saberlist = "saberlist.make:saberlist"
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]

View File

@ -111,7 +111,7 @@ def _parse_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response *, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Optional[Union[Any, CompactScoreResponseResponseWithMetadata]]: ) -> Optional[Union[Any, CompactScoreResponseResponseWithMetadata]]:
if response.status_code == HTTPStatus.OK: if response.status_code == HTTPStatus.OK:
response_200 = CompactScoreResponseResponseWithMetadata.from_dict(response.text) response_200 = CompactScoreResponseResponseWithMetadata.from_dict(response.json())
return response_200 return response_200
if response.status_code == HTTPStatus.BAD_REQUEST: if response.status_code == HTTPStatus.BAD_REQUEST:

View File

@ -111,7 +111,7 @@ def _parse_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response *, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Optional[Union[Any, ScoreResponseWithMyScoreResponseWithMetadata]]: ) -> Optional[Union[Any, ScoreResponseWithMyScoreResponseWithMetadata]]:
if response.status_code == HTTPStatus.OK: if response.status_code == HTTPStatus.OK:
response_200 = ScoreResponseWithMyScoreResponseWithMetadata.from_dict(response.text) response_200 = ScoreResponseWithMyScoreResponseWithMetadata.from_dict(response.json())
return response_200 return response_200
if response.status_code == HTTPStatus.BAD_REQUEST: if response.status_code == HTTPStatus.BAD_REQUEST:

View File

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

View File

@ -77,7 +77,6 @@ class ScoreSaberAPI:
return json.load(f) return json.load(f)
logging.debug(f"Fetching fresh data for player {player_id}") logging.debug(f"Fetching fresh data for player {player_id}")
url_player_scores = f"/api/player/{player_id}/scores"
all_scores = [] all_scores = []
page = 1 page = 1
@ -94,7 +93,7 @@ class ScoreSaberAPI:
) )
except Exception as e: except Exception as e:
logging.error(f"Error fetching page {page} for player {player_id}: {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]) all_scores.extend([score.dict() for score in response.player_scores])

View File

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

View File

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

View File

@ -9,7 +9,7 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper() LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper()
HISTORY_FILE = os.environ.get('HISTORY_FILE', "playlist_history.json") 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 import logging
logging.basicConfig( logging.basicConfig(
@ -20,6 +20,7 @@ logging.basicConfig(
from helpers.PlaylistBuilder import PlaylistBuilder from helpers.PlaylistBuilder import PlaylistBuilder
from helpers.ScoreSaberAPI import ScoreSaberAPI from helpers.ScoreSaberAPI import ScoreSaberAPI
from helpers.BeatLeaderAPI import BeatLeaderAPI
def load_history() -> Dict[str, Any]: 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: 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. Uses a default ID if the user presses Enter without input.
:param default_id: The default player ID to use. :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 days = time_difference.days
if days < 7: if days < 7:
return f"{days} days(s)" return f"{days} day(s)"
elif days < 30: elif days < 30:
weeks = days // 7 weeks = days // 7
return f"{weeks} week(s)" return f"{weeks} week(s)"
@ -74,171 +75,138 @@ def format_time_ago(time_difference: timedelta) -> str:
years = days // 365 years = days // 365
return f"{years} year(s)" return f"{years} year(s)"
def normalize_difficulty(difficulty_name): def normalize_difficulty_name(difficulty_name):
# ScoreSaber difficulty naming convention difficulty_names = {
difficulty_map = { # ScoreSaber
'_ExpertPlus_SoloStandard': 'expertplus', '_ExpertPlus_SoloStandard': 'expertplus',
'_Expert_SoloStandard': 'expert', '_Expert_SoloStandard': 'expert',
'_Hard_SoloStandard': 'hard', '_Hard_SoloStandard': 'hard',
'_Normal_SoloStandard': 'normal', '_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( def playlist_strategy_beatleader_oldscores(
api: ScoreSaberAPI, api: BeatLeaderAPI,
song_count: int = 10 song_count: int = 40
) -> List[Dict[str, Any]]: ) -> 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() player_id = prompt_for_player_id()
history = load_history() 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', []) all_scores = scores_data.get('playerScores', [])
if not all_scores: 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 [] 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) # Sort scores by epochTime in ascending order (oldest first)
all_scores.sort(key=lambda x: x['score'].get('timeSet', '')) all_scores.sort(key=lambda x: x.get('score', {}).get('epochTime', 0))
difficulty_groups = defaultdict(list) playlist_data = []
no_stars_group = []
current_time = datetime.now(timezone.utc) current_time = datetime.now(timezone.utc)
for score in all_scores: for score_entry in all_scores:
leaderboard = score.get('leaderboard', {}) if len(playlist_data) >= song_count:
stars = leaderboard.get('stars', 0) break # Stop if we've reached the desired number of songs
song_id = leaderboard.get('songHash')
difficulty_raw = leaderboard.get('difficulty', {}).get('difficultyRaw', '')
if not song_id or not difficulty_raw: score = score_entry.get('score', {})
logging.debug(f"Skipping score due to missing song_id or difficulty_raw: {score}") leaderboard = score_entry.get('leaderboard', {})
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 # 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: try:
time_set = datetime.fromisoformat(time_set_str.replace('Z', '+00:00')) time_set = datetime.fromtimestamp(epoch_time, tz=timezone.utc)
except ValueError as e: except (ValueError, OSError) as e:
logging.error(f"Invalid time format for score ID {score['score'].get('id')}: {e}") logging.error(f"Invalid epochTime for score ID {score.get('id')}: {e}")
continue continue
time_difference = current_time - time_set time_difference = current_time - time_set
time_ago = format_time_ago(time_difference) time_ago = format_time_ago(time_difference)
# Normalize the difficulty name # Format the song data for PlaylistBuilder
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
song_dict = { song_dict = {
'hash': song_id, 'hash': song_hash,
'songName': leaderboard.get('songName', 'Unknown'),
'difficulties': [ 'difficulties': [
{ {
'name': difficulty, 'name': difficulty,
'characteristic': leaderboard.get('difficulty', {}).get('gameMode', 'Standard') 'characteristic': game_mode
} }
] ]
} }
# Group songs based on stars # Add the song to the playlist
if stars is None or stars == 0: playlist_data.append(song_dict)
no_stars_group.append(song_dict) logging.debug(f"Selected song for playlist: Hash={song_hash}, Difficulty={difficulty}. Last played {time_ago} ago.")
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)
playlist_data = [] # Update history
history['beatleader_oldscores'].setdefault(song_hash, []).append(difficulty)
# Define the order and counts for each difficulty group # Log the final playlist
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
if not playlist_data: 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: else:
# Log details of each song added to the playlist
for song in playlist_data: for song in playlist_data:
song_name = song['songName'] song_hash = song['hash']
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
difficulty = song['difficulties'][0]['name'] difficulty = song['difficulties'][0]['name']
logging.info(f"Song added: {song_name} ({difficulty}), mapped by {mapper}. Last played {time_ago} ago.") logging.info(f"Song added: Hash={song_hash}, Difficulty={difficulty}.")
logging.info(f"Total songs added to playlist: {len(playlist_data)}") 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) save_history(history)
return playlist_data 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. 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. 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. 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}" playlist_name = f"{strategy}-{timestamp}"
if strategy == 'oldscores': if strategy == 'scoresaber_oldscores':
playlist_data = playlist_strategy_oldscores(api, song_count=10) 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: if not playlist_data:
logging.info("No new scores found to add to the playlist.") 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_title=playlist_name,
playlist_author="SaberList Tool" 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)
"""

View File

@ -4,7 +4,7 @@ import os
import json import json
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from saberlist.PlaylistBuilder import PlaylistBuilder from helpers.PlaylistBuilder import PlaylistBuilder
@pytest.fixture @pytest.fixture
def temp_environment(tmp_path): def temp_environment(tmp_path):