diff --git a/README.md b/README.md index 54e2ad8..651a4fa 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/convert-comyfui-outupt.sh b/convert-comyfui-outupt.sh new file mode 100755 index 0000000..ffe5117 --- /dev/null +++ b/convert-comyfui-outupt.sh @@ -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!" diff --git a/docs/OLDREADME.md b/docs/OLDREADME.md new file mode 100644 index 0000000..79a4302 --- /dev/null +++ b/docs/OLDREADME.md @@ -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 +``` diff --git a/pyproject.toml b/pyproject.toml index eadfed5..6f7bfbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/src/saberlist/beatleader.py b/src/saberlist/beatleader.py index d457ffe..1d42a50 100644 --- a/src/saberlist/beatleader.py +++ b/src/saberlist/beatleader.py @@ -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() \ No newline at end of file + saberlist_replay_bl() \ No newline at end of file diff --git a/src/saberlist/beatleaderAPI.py b/src/saberlist/beatleaderAPI.py index 0bbe76c..aa97f6a 100644 --- a/src/saberlist/beatleaderAPI.py +++ b/src/saberlist/beatleaderAPI.py @@ -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) \ No newline at end of file + 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 \ No newline at end of file