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

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"
#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"]

View File

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

View File

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

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

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

View File

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