Rewrite BeatLeader class and add an oldscore strategy for it.
This commit is contained in:
parent
76e796eb16
commit
2d87cdd59e
@ -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
|
||||||
|
26
docs/ClientWrapperUsage.md
Normal file
26
docs/ClientWrapperUsage.md
Normal 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
11
docs/Python.md
Normal 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)
|
||||||
|
```
|
78
docs/SimpleBeatLeaderAPI.md
Normal file
78
docs/SimpleBeatLeaderAPI.md
Normal 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.
|
@ -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.
|
|
543
docs/prompts/01-scratchpad.md
Normal file
543
docs/prompts/01-scratchpad.md
Normal 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
216
docs/prompts/01-template.md
Normal 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
180
docs/prompts/02-scratchpad.md
Normal file
180
docs/prompts/02-scratchpad.md
Normal file
File diff suppressed because one or more lines are too long
599
docs/prompts/03-scratchpad.md
Normal file
599
docs/prompts/03-scratchpad.md
Normal 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.
|
@ -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"]
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
130
src/helpers/BeatLeaderAPI.py
Normal file
130
src/helpers/BeatLeaderAPI.py
Normal 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
|
@ -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])
|
||||||
|
|
||||||
|
133
src/helpers/SimpleBeatLeaderAPI.py
Normal file
133
src/helpers/SimpleBeatLeaderAPI.py
Normal 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
|
@ -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()
|
|
||||||
"""
|
|
@ -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)
|
||||||
|
"""
|
@ -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):
|
||||||
|
Loading…
Reference in New Issue
Block a user