Use generated cover images if they exist for our playlists.

This commit is contained in:
Brian Lee 2024-09-17 09:05:26 -07:00
parent f69e67de6f
commit f298623cbe
6 changed files with 282 additions and 86 deletions

View File

@ -1,81 +1,46 @@
# playlist helper
# SaberList
SaberList is a tool for generating custom Beat Saber playlists based on a player's performance data from Beat Leader.
TODO: use https://github.com/megamaz/beatsaver-python
## Features
## Usage
- Fetches player scores from Beat Leader API
- Generates difficulty-based playlists
- Automatically selects a random cover image for each playlist
- Avoids duplicating songs across multiple playlist generations
- Caches player data for faster subsequent runs
## Playlist generations
The program has the following playlist generation modes:
### Replay songs by age
```sh
pipx install pleb-saberlist
player_scores_by_stars
leaderboard_songs_by_stars
saberlist_replay_bl
```
### Examples
This will generate a playlist of oldest songs that you have previously played, ostensibly because you probably can improve your score. It will add low star songs, mid star songs, and high star songs to the playlist. That way you can warm up on the low star songs, and then move on to the harder songs. Each time you run this command it will generate a completely new playlist.
```sh
leaderboard_songs_by_stars
Enter the minimum starlevel to include on the playlist (Default: 6.0): 6.2
Enter the maximum starlevel to include on the playlist (Default: 6.3):
Enter the filename for the playlist (Default: SS Leaderboard 6.2★):
2024-07-09 16:06:13 DEBUG: Using selector: EpollSelector
2024-07-09 16:06:13 INFO: Fetching page 1/-1 of leaderboards
2024-07-09 16:06:13 INFO: Got 14 songs from leaderboard page 1
2024-07-09 16:06:13 INFO: Fetching page 2/3 of leaderboards
2024-07-09 16:06:14 INFO: Got 14 songs from leaderboard page 2
2024-07-09 16:06:14 INFO: Fetching page 3/3 of leaderboards
2024-07-09 16:06:14 INFO: Got 3 songs from leaderboard page 3
2024-07-09 16:06:14 INFO: Playlist written to SS Leaderboard 6.2★.bplist
```
## Covers
Player songs by stars:
The program will automatically select a random cover image for each playlist. The cover image is selected from the `covers` directory. We suggest using a latent diffusion model to generate random cover images for your playlists.
```sh
player_scores_by_stars
2024-07-09 16:07:04 DEBUG: Using selector: EpollSelector
Enter the playerid (Default: 76561199407393962):
Enter the minimum starlevel to include on the playlist (Default: 5):
Enter the maximum starlevel to include on the playlist (Default: 6.0): 5.5
Enter the filename for the playlist (Default: Replay SS 5.0★):
2024-07-09 16:07:14 INFO: Fetching page 1/-1 of player scores
2024-07-09 16:07:15 INFO: Got 100 scores from page 1
...
2024-07-09 16:07:19 INFO: Fetching page 8/9 of player scores
2024-07-09 16:07:20 INFO: Got 96 scores from page 8
2024-07-09 16:07:20 INFO: Playlist written to Replay SS 5.0★.bplist
## Configuration
```
The program uses a `playlist_history.json` file to keep track of previously used songs and cover images. This ensures that subsequent runs generate fresh playlists without duplicates.
## Development
## Output
Clone the repo and install dependencies into a local virtual environment:
The program generates:
```bash
pip install --upgrade pip
pip install --editable .
```
1. A `.bplist` file containing the playlist data in JSON format
2. Console output listing the songs included in the playlist
## Resources
## Contributing
* [Beatleader API](https://beatleader.xyz/developer)
* [ScoreSaber API](https://docs.scoresaber.com/)
Contributions are welcome! Please feel free to submit a Pull Request.
## Tips
## License
Count results
```shell
jq '.songs | length' < playlist.bplist
```
Avoid printing covers in console.
```shell
jq 'del(.image)' < playlist.bplist
```
Find the most common mappers the player has played:
```shell
jq -r '.songs[].levelAuthorName' *.bplist | sort | uniq -c | sort -rn | head -n 10
```
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

50
convert-comyfui-outupt.sh Executable file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# ===============================================================
# PNG to JPEG Converter for ComfyUI Output
# ===============================================================
#
# Purpose:
# This script converts PNG images generated by ComfyUI to JPEG format.
# It's designed to process images in the 'comfyui-output/dev-dancing'
# directory and save the converted files in a './covers' directory.
#
# Features:
# - Converts PNG to JPEG using ImageMagick (magick convert)
# - Applies '-strip' to remove metadata and '-quality 90' for compression
# - Skips already converted images to allow for incremental processing
# - Creates the output directory if it doesn't exist
#
# Usage:
# ./convert_to_jpg.sh
#
# Note: Ensure ImageMagick is installed on your system before running.
#
# Author: [Your Name]
# Date: [Current Date]
# Version: 1.0
#
# ===============================================================
# Create the output directory if it doesn't exist
mkdir -p ./covers
# Loop through all PNG files in the comfyui-output/dev-dancing directory
for png_file in comfyui-output/dev-dancing/*.png; do
# Get the base filename without extension
base_name=$(basename "$png_file" .png)
# Define the output JPEG filename
jpg_file="./covers/${base_name}.jpg"
# Check if the JPEG file already exists
if [ ! -f "$jpg_file" ]; then
# Convert PNG to JPEG using magick with specified options
magick "$png_file" -strip -quality 90 "$jpg_file"
echo "Converted: $png_file -> $jpg_file"
else
echo "Skipped: $jpg_file already exists"
fi
done
echo "Conversion complete!"

80
docs/OLDREADME.md Normal file
View File

@ -0,0 +1,80 @@
# playlist helper
This is the old readme that explains the scoresaber functions.
## Usage
```sh
pipx install pleb-saberlist
player_scores_by_stars
leaderboard_songs_by_stars
```
### Examples
```sh
leaderboard_songs_by_stars
Enter the minimum starlevel to include on the playlist (Default: 6.0): 6.2
Enter the maximum starlevel to include on the playlist (Default: 6.3):
Enter the filename for the playlist (Default: SS Leaderboard 6.2★):
2024-07-09 16:06:13 DEBUG: Using selector: EpollSelector
2024-07-09 16:06:13 INFO: Fetching page 1/-1 of leaderboards
2024-07-09 16:06:13 INFO: Got 14 songs from leaderboard page 1
2024-07-09 16:06:13 INFO: Fetching page 2/3 of leaderboards
2024-07-09 16:06:14 INFO: Got 14 songs from leaderboard page 2
2024-07-09 16:06:14 INFO: Fetching page 3/3 of leaderboards
2024-07-09 16:06:14 INFO: Got 3 songs from leaderboard page 3
2024-07-09 16:06:14 INFO: Playlist written to SS Leaderboard 6.2★.bplist
```
Player songs by stars:
```sh
player_scores_by_stars
2024-07-09 16:07:04 DEBUG: Using selector: EpollSelector
Enter the playerid (Default: 76561199407393962):
Enter the minimum starlevel to include on the playlist (Default: 5):
Enter the maximum starlevel to include on the playlist (Default: 6.0): 5.5
Enter the filename for the playlist (Default: Replay SS 5.0★):
2024-07-09 16:07:14 INFO: Fetching page 1/-1 of player scores
2024-07-09 16:07:15 INFO: Got 100 scores from page 1
...
2024-07-09 16:07:19 INFO: Fetching page 8/9 of player scores
2024-07-09 16:07:20 INFO: Got 96 scores from page 8
2024-07-09 16:07:20 INFO: Playlist written to Replay SS 5.0★.bplist
```
## Development
Clone the repo and install dependencies into a local virtual environment:
```bash
pip install --upgrade pip
pip install --editable .
```
## Resources
* [Beatleader API](https://beatleader.xyz/developer)
* [ScoreSaber API](https://docs.scoresaber.com/)
## Tips
Count results
```shell
jq '.songs | length' < playlist.bplist
```
Avoid printing covers in console.
```shell
jq 'del(.image)' < playlist.bplist
```
Find the most common mappers the player has played:
```shell
jq -r '.songs[].levelAuthorName' *.bplist | sort | uniq -c | sort -rn | head -n 10
```

View File

@ -34,10 +34,10 @@ Homepage = "https://git.satstack.dev/blee/beatsaber-playlist-tool"
# https://setuptools.pypa.io/en/latest/userguide/entry_point.html
[project.scripts]
player_scores_by_stars = "saberlist.scoresaber:replay_ranked"
replay_all_by_acc = "saberlist.scoresaber:replay_all_by_acc"
leaderboard_songs_by_stars = "saberlist.scoresaber:leaderboard_songs"
star_ladder = "saberlist.beatleader:star_ladder"
#player_scores_by_stars = "saberlist.scoresaber:replay_ranked"
#replay_all_by_acc = "saberlist.scoresaber:replay_all_by_acc"
#leaderboard_songs_by_stars = "saberlist.scoresaber:leaderboard_songs"
saberlist_replay_bl = "saberlist.beatleader:saberlist_replay_bl"
[tool.pytest.ini_options]
pythonpath = ["src"]

View File

@ -1,35 +1,54 @@
import json
import os
from saberlist.beatleaderAPI import BeatLeaderAPI
from collections import defaultdict
from datetime import datetime
from saberlist.beatleaderAPI import BeatLeaderAPI
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
)
HISTORY_FILE = "playlist_history.json"
def load_history():
if os.path.exists(HISTORY_FILE):
with open(HISTORY_FILE, 'r') as f:
return json.load(f)
return {}
history = json.load(f)
# Ensure 'used_covers' key exists
if 'used_covers' not in history:
history['used_covers'] = []
return history
return {'used_covers': []}
def save_history(history):
with open(HISTORY_FILE, 'w') as f:
json.dump(history, f, indent=2)
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):
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']['stars']
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 0 <= stars <= 3:
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)
@ -48,6 +67,15 @@ def build_difficulty_based_playlist(api, player_id, history, playlist_name):
playlist_scores.extend(sorted(unique_songs.values(), key=lambda x: x['timepost'])[:count])
# Add 5 songs with no star value or zero stars
unique_no_stars_songs = {}
for score in no_stars_group:
song_id = score['leaderboard']['song']['id']
if song_id not in unique_no_stars_songs or score['timepost'] < unique_no_stars_songs[song_id]['timepost']:
unique_no_stars_songs[song_id] = score
playlist_scores.extend(sorted(unique_no_stars_songs.values(), key=lambda x: x['timepost'])[:5])
# Update history
for score in playlist_scores:
song_id = score['leaderboard']['song']['id']
@ -56,24 +84,35 @@ def build_difficulty_based_playlist(api, player_id, history, playlist_name):
history[song_id] = []
history[song_id].append(difficulty_name)
playlist_file = api.create_bplist(playlist_scores, playlist_name)
playlist_file, used_cover = api.create_player_playlist_with_random_cover(
player_id, playlist_name, "SaberList Tool", len(playlist_scores), True, set(history['used_covers'])
)
return playlist_file, playlist_scores
if used_cover:
history['used_covers'].append(used_cover)
return playlist_file, playlist_scores, used_cover
def star_ladder():
def saberlist_replay_bl():
api = BeatLeaderAPI()
player_id = '76561199407393962'
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()
# Generate a unique playlist name
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
playlist_name = f"star_ladder-{timestamp}"
playlist_file, playlist_scores = build_difficulty_based_playlist(api, player_id, history, playlist_name)
playlist_file, playlist_scores, used_cover = build_difficulty_based_playlist(api, player_id, history, playlist_name)
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']
@ -81,4 +120,4 @@ def star_ladder():
print(f"{i}. {song['name']} by {song['author']} (Mapper: {song['mapper']}) - {difficulty['stars']:.2f} stars - Last played: {datetime.fromtimestamp(score['timepost'])}")
if __name__ == "__main__":
star_ladder()
saberlist_replay_bl()

View File

@ -1,9 +1,11 @@
import requests
from datetime import datetime, timedelta
import base64
import json
import os
from datetime import datetime, timedelta
import logging
import random
import requests
import time
import logging
logging.basicConfig(
format='%(asctime)s %(levelname)s: %(message)s',
@ -139,7 +141,8 @@ class BeatLeaderAPI:
def get_cache_dir(self):
return self.CACHE_DIR
def create_bplist(self, scores, playlist_title="playlist", playlist_author="SaberList Tool", song_limit=0):
def create_bplist(self, scores, playlist_title="playlist", playlist_author="SaberList Tool", song_limit=0, cover_image=None):
"""
Create a bplist (JSON) file in the current directory from the given scores data.
@ -147,6 +150,7 @@ class BeatLeaderAPI:
:param playlist_title: Title of the playlist (default: "playlist")
:param playlist_author: Author of the playlist (default: "SaberList Tool")
:param song_limit: Maximum number of songs to include (0 for no limit)
:param cover_image: Path to the cover image file (optional)
:return: Path to the created bplist file
"""
playlist = {
@ -155,6 +159,11 @@ class BeatLeaderAPI:
"songs": []
}
if cover_image:
with open(cover_image, "rb") as image_file:
encoded_image = base64.b64encode(image_file.read()).decode('utf-8')
playlist["image"] = f"data:image/jpeg;base64,{encoded_image}"
# Determine the number of songs to include
num_songs = len(scores) if song_limit == 0 else min(song_limit, len(scores))
@ -199,4 +208,57 @@ class BeatLeaderAPI:
:return: Path to the created bplist file
"""
scores_data = self.get_player_scores(player_id, use_cache=use_cache)
return self.create_bplist(scores_data['data'], playlist_title, playlist_author, song_limit)
return self.create_bplist(scores_data['data'], playlist_title, playlist_author, song_limit)
def create_player_playlist_with_random_cover(self, player_id, playlist_title="playlist", playlist_author="SaberList Tool", song_limit=0, use_cache=True, used_covers=None):
"""
Create a bplist (JSON) file for a player's scores with a random cover image.
:param player_id: ID of the player
:param playlist_title: Title of the playlist (default: "playlist")
:param playlist_author: Author of the playlist (default: "SaberList Tool")
:param song_limit: Maximum number of songs to include (0 for no limit)
:param use_cache: Whether to use cached scores data (default: True)
:param used_covers: Set of already used cover image filenames
:return: Path to the created bplist file, and the filename of the used cover
"""
scores_data = self.get_player_scores(player_id, use_cache=use_cache)
covers_dir = "./covers"
# Create the covers directory if it doesn't exist
if not os.path.exists(covers_dir):
os.makedirs(covers_dir)
logging.info(f"Created directory: {covers_dir}")
available_covers = [f for f in os.listdir(covers_dir) if f.endswith('.jpg') and f not in (used_covers or set())]
if not available_covers:
logging.warning("No unused cover images available. Using no cover.")
return self.create_bplist(scores_data['data'], playlist_title, playlist_author, song_limit), None
selected_cover = random.choice(available_covers)
cover_path = os.path.join(covers_dir, selected_cover)
playlist_file = self.create_bplist(scores_data['data'], playlist_title, playlist_author, song_limit, cover_path)
return playlist_file, selected_cover
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