From 9a5d6651c1ff8ad39cba320b50be356f2b27f373 Mon Sep 17 00:00:00 2001 From: Jonas Krogh <36476478+Badgie@users.noreply.github.com> Date: Wed, 11 Mar 2020 11:42:29 +0100 Subject: [PATCH] Version 1.2 (#80) * Rework config and save to playlist arg (#68) * rework config file & make config-modifying arguments more uniform * add custom config parser * finalize new config structure * clean up conf file and add some print output * allow adding and removing currently playing track to input playlist * update readme * reformat some older functions * minor bug fixes * add and update comments * update intro text in readme * remove redundant non-positional parameter assignments in function calls * Update conf.py Co-Authored-By: Falke Carlsen * update readme Co-authored-by: Falke Carlsen * resolve conflicts * resolve conflicts * resolve conflicts * Track feature printing (#75) * rework config file & make config-modifying arguments more uniform * add custom config parser * finalize new config structure * clean up conf file and add some print output * allow adding and removing currently playing track to input playlist * update readme * reformat some older functions * minor bug fixes * add and update comments * update intro text in readme * remove redundant non-positional parameter assignments in function calls * allow printing track features * Enhance tune validation (#74) * rework config file & make config-modifying arguments more uniform * add custom config parser * finalize new config structure * clean up conf file and add some print output * allow adding and removing currently playing track to input playlist * update readme * reformat some older functions * minor bug fixes * add and update comments * update intro text in readme * remove redundant non-positional parameter assignments in function calls * enhance validity check of tunining * add version flag (#61) * remove whitespace error in custom schemes (#62) * Single playlist (#65) * use single playlist * update readme * Add tuning option to print (#67) * add tuning option to print * update readme to reflect install changes * spelling * Catch keyboard interrupts (#60) * catch keyboard interrupts * catch interrupt on new input prompts * add flag for changing playback device (#77) * notify when recommendation yields few different tracks (#78) * fix minor things before release Co-authored-by: Falke Carlsen --- README.md | 127 ++++++--- api.py | 99 ++++++- conf.py | 283 +++++++++++++++++++ oauth2.py | 26 +- spotirec.py | 775 +++++++++++++++++++++++++++++++++------------------- tuning-opts | 15 + 6 files changed, 991 insertions(+), 334 deletions(-) create mode 100644 conf.py create mode 100644 tuning-opts diff --git a/README.md b/README.md index 9e84e86..19c29a0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

# Spotirec -Script that creates a playlist of recommendations based on the user's top artists or tracks, or genres extracted from top artists. A sort of Discover Weekly on demand. +A tool that can create a playlist of recommendations based on the user's top artists or tracks, or genres extracted from top artists with various parameters - a sort of Discover Weekly on demand. Also includes functionality for various other Spotify-related actions, such as saving the currently playing track. ## Table of Contents - [Installation](#installation) @@ -14,12 +14,15 @@ Script that creates a playlist of recommendations based on the user's top artist - [Manual](#manual) - [Usage](#usage) - [Recommendation Schemes](#recommendation-schemes) + - [Preserving Playlists](#preserving-playlists) - [Limits](#limits) - [Presets](#presets) - [Tuning](#tuning) - [Blacklists](#blacklists) - [Autoplay](#autoplay) - [Devices](#devices) + - [Saving Playlists](#saving-playlists) + - [Saving Tracks](#saving-tracks) - [Printing](#printing) - [Troubleshooting](#troubleshooting) @@ -33,7 +36,7 @@ yay -S spotirec-git ``` #### Manual -On any other distribution you need to install Spotirec manually. Spotirec has two dependencies +On any other distribution you need to install Spotirec manually. Spotirec has three dependencies ``` bottle>=0.12.17 requests>=2.22.0 @@ -50,7 +53,8 @@ mkdir -p /usr/lib/spotirec mkdir -p /usr/bin mkdir -p $HOME/.config/spotirec -install spotirec.py oauth2.py recommendation.py api.py -t /usr/lib/spotirec +install tuning-opts -t $HOME/.config/spotirec +install spotirec.py oauth2.py conf.py recommendation.py api.py -t /usr/lib/spotirec ln -s /usr/lib/spotirec/spotirec.py /usr/bin/spotirec ``` @@ -100,6 +104,9 @@ $ spotirec -t 4 ``` Note that if this option is used with no-arg, it **must** be the very first argument +### Preserving Playlists +By default, Spotirec caches the id of the first playlist created and uses this every time new recommendations are requested, meaning that any old tracks are overwritten. To avoid this and create a new playlist instead, pass the `--preserve` flag. + ### Limits You can add a limit as an integer value with the `-l` argument ``` @@ -108,17 +115,22 @@ $ spotirec -l 50 This option determines how many tracks should be added to your new playlist. The default value is 20, the minimum value is 1, and the max value is 100. ### Presets -You can save the settings for a recommendation with the `-ps` argument followed by a name +You can save the settings for a recommendation with the `--save-preset` flag followed by a name +``` +$ spotirec -t --save-preset preset_name -l 50 --tune prefix_attribute=value prefix_attribute=value ``` -$ spotirec -t -ps name -l 50 --tune prefix_attribute=value prefix_attribute=value +To load and use a saved preset, pass the `--load-preset` flag followed by the name of the preset ``` -To load and use a saved preset, pass the `-p` argument followed by the name of the preset +$ spotirec --load-preset preset_name ``` -$ spotirec -p name +To remove one or more saved presets, pass the `--remove-presets` flag followed by a sequence of preset names ``` +$ spotirec --remove-presets preset_name0 preset_name1 preset_name2 +``` +If you forgot which presets you have saved, see [printing](#printing) ### Tuning -You can also specify tunable attributes with the `--tune` option, followed by any number of whitespace separated arguments on the form `prefix_attribute=value` +You can also specify tunable attributes with the `--tune` flag, followed by any number of whitespace separated inputs on the form `prefix_attribute=value` ``` $ spotirec --tune prefix_attribute=value prefix_attribute=value ``` @@ -138,67 +150,118 @@ $ spotirec --tune prefix_attribute=value prefix_attribute=value | key | int | 0-11 | N/A | [Pitch class](https://en.wikipedia.org/wiki/Pitch_class#Other_ways_to_label_pitch_classes) of the track. | | mode | int | 0-1 | N/A | Modality of the track. 1 is major, 0 is minor. | | time_signature | int | N/A | N/A | Estimated overall [time signature](https://en.wikipedia.org/wiki/Time_signature) of the track. | -| popularity | int | 0-100 | 0-100 | Popularity of the track. High is popular, low is barely known | -| acousticness | float | 0.0-1.0 | Any | Confidence measure for whether or not the track is acoustic. High value is acoustic. | +| popularity | int | 0-100 | 0-100 | Popularity of the track. High is popular, low is barely known. | +| acousticness | float | 0.0-1.0 | 0.0-1.0 | Confidence measure for whether or not the track is acoustic. High value is acoustic. | | danceability | float | 0.0-1.0 | 0.1-0.9 | How well fit a track is for dancing. Measurement includes among others tempo, rhythm stability, and beat strength. High value is suitable for dancing. | -| energy | float | 0.0-1.0 | Any | Perceptual measure of intensity and activity. High energy is fast, loud, and noisy, and low is slow and mellow. | +| energy | float | 0.0-1.0 | 0.0-1.0 | Perceptual measure of intensity and activity. High energy is fast, loud, and noisy, and low is slow and mellow. | | instrumentalness | float | 0.0-1.0 | 0.0-1.0 | Whether or not a track contains vocals. Low contains vocals, high is purely instrumental. | | liveness | float | 0.0-1.0 | 0.0-0.4 | Predicts whether or not a track is live. High value is live. | | loudness | float | -60-0 | -20-0 | Overall loudness of the track, measured in decibels. | -| speechiness | float | 0.0-1.0 | 0.0-0.3 | Presence of spoken words. Low is a song, and high is likely to be a talk show or podcast. | -| valence | float | 0.0-1.0 | Any | Positivity of the track. High value is positive, and low value is negative. | +| speechiness | float | 0.0-1.0 | 0.0-0.3 | Presence of spoken words. Low is a song, high is likely to be a talk show or podcast. | +| valence | float | 0.0-1.0 | 0.0-1.0 | Positivity of the track. High value is positive, low value is negative. | | tempo | float | 0.0-220.0 | 60.0-210.0 | Overall estimated beats per minute of the track. | -Recommendations may be sparce outside the recommended range. +Recommendations may be scarce outside the recommended range. ### Blacklists -To blacklist tracks or artists, pass the `-b` option followed by an arbitrary number of whitespace separated Spotify URIs +To blacklist tracks or artists, pass the `-b` argument followed by an arbitrary number of whitespace separated Spotify URIs ``` $ spotirec -b spotify:track:id spotify:track:id spotify:artist:id ``` -To remove entries from your blacklist, pass the `-br` option followed by an arbitrary number of whitespace separated Spotify URIs +To remove entries from your blacklist, pass the `-br` argument followed by an arbitrary number of whitespace separated Spotify URIs ``` $ spotirec -br spotify:track:id spotify:track:id spotify:artist:id ``` +To blacklist the currently playing track, or the artists that created the track, pass the `-bc` argument followed by either 'artist' or 'track' +``` +$ spotirec -bc track +$ spotirec -bc artist +``` +If you forgot which tracks and artists you have blacklisted, see [printing](#printing) ### Autoplay -You can also automatically play your new playlist upon creation using the `--play` option - here you will be prompted to select which device you want to start the playback on +You can also automatically play your new playlist upon creation using the `--play` flag followed by a name of a saved device - see [devices](#devices) +``` +$ spotirec --play device_name ``` -$ spotirec --play -Available devices: + +### Devices +You can save devices using the `--save-device` flag, whereafter you will be prompted to select a device from your currently connected devices, and to input a name that will serve as an identifier +``` +$ spotirec --save-device Name Type ----------------------------------------- 0. Phone Smartphone 1. Laptop Computer -Please select a device by index number [default: Phone]: 1 -Would you like to save "Laptop" for later use? [y/n] y +Select a device by index [0]: 1 Enter an identifier for your device: laptop -Saved device "Laptop" as "laptop" +Added device laptop to config ``` -You will also be asked if you want to save the device in your config for later use. If you choose to do so, you can use the `--play-device` option followed by an identifier for a device to play on a saved device +To remove one or more saved devices, pass the `--remove-devices` flag followed by a sequence of names for devices ``` -$ spotirec --play-device laptop +$ spotirec --remove-devices device_name0 device_name1 device_name2 ``` +If you forgot which devices you have saved, see [printing](#printing) -### Devices -You can also manually save devices using the `-d` option, which provides the same functionality as `--play` without creating a playlist +### Saving playlists +You can save playlists using the `--save-playlist` flag, whereafter you will be prompted to input an identifier for the playlist, and then a URI for the playlist. For further usage of this, see [saving tracks](#saving-tracks) +``` +$ spotirec --save-playlist +Please input an identifier for your playlist: test +Please input the URI for your playlist: spotify:playlist:0Vu97Y7WoJgBlFzAwbrZ8h +Added playlist test to config +``` +To remove one or more saved playlists, pass the `--remove-playlists` flag followed by a sequence of names for playlists +``` +$ spotirec --remove-playlists playlist_name0 playlist_name1 playlist_name2 +``` +If you forgot which playlists you have saved, see [printing](#printing) + +### Saving Tracks +To like the currently playing track, pass the `-s` argument ``` -$ spotirec -d +$ spotirec -s ``` -To remove a saved device, pass the `-dr` option followed by an identifier for a device +To remove the currently playing track from liked tracks, pass the `-sr` argument ``` -$ spotirec -dr laptop +$ spotirec -sr +``` + +To add the currently playing track to a specific playlist, pass the `--add-to` flag followed by a name for a saved playlist, or a playlist URI +``` +$ spotirec --add-to playlist_name +$ spotirec --add-to spotify:playlist:0Vu97Y7WoJgBlFzAwbrZ8h +``` + +To remove the currently playing track from a specific playlist, pass the `--remove-from` flag followed by a name for a saved playlist, or a playlist URI +``` +$ spotirec --remove-from playlist_name +$ spotirec --remove-from spotify:playlist:0Vu97Y7WoJgBlFzAwbrZ8h ``` ### Printing -You can print lists of various data contained within your Spotify account and config files using the `--print` argument followed by `[artists|tracks|genres|genre-seeds|blacklist|devices]` +You can print lists of various data contained within your Spotify account and config files using the `--print` flag followed by any of the following strings, depending on what you would like to print ``` $ spotirec --print artists $ spotirec --print tracks $ spotirec --print genres $ spotirec --print genre-seeds -$ spotirec --print blacklist $ spotirec --print devices +$ spotirec --print blacklist +$ spotirec --print presets +$ spotirec --print playlists +$ spotirec --print tuning +``` + +You can also print various features of a track with the `--track-features` flag followed by either a URI or 'current' if you want information about the currently playing track. Features include track attributes (as used in [tuning](#tuning)) and URIs. +``` +$ spotirec --track-features current +$ spotirec --track-features spotify:track:4uLU6hMCjMI75M1A2tKUQC +``` + +### Playback +You can change playback to a different device by passing the `--transfer-playback` device followed by an identifier for a saved device +``` +$ spotirec --transfer-playback phone ``` ## Troubleshooting diff --git a/api.py b/api.py index 9e89202..34eb306 100644 --- a/api.py +++ b/api.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import json import requests +import conf url_base = 'https://api.spotify.com/v1' @@ -47,9 +48,10 @@ def get_user_id(headers: dict) -> str: return json.loads(response.content.decode('utf-8'))['id'] -def create_playlist(playlist_name: str, playlist_description: str, headers: dict) -> str: +def create_playlist(playlist_name: str, playlist_description: str, headers: dict, cache_id=False) -> str: """ Creates playlist on user's account. + :param cache_id: whether playlist id should be saved as default or not :param playlist_name: name of the playlist :param playlist_description: description of the playlist :param headers: request headers @@ -60,7 +62,10 @@ def create_playlist(playlist_name: str, playlist_description: str, headers: dict print('Creating playlist') response = requests.post(f'{url_base}/users/{get_user_id(headers)}/playlists', json=data, headers=headers) error_handle('playlist creation', 201, 'POST', response=response) - return json.loads(response.content.decode('utf-8'))['id'] + playlist = json.loads(response.content.decode('utf-8')) + if cache_id: + conf.save_playlist({'name': playlist['name'], 'uri': playlist['uri']}, 'spotirec-default') + return playlist['id'] def upload_image(playlist_id: str, data: str, img_headers: dict): @@ -187,3 +192,93 @@ def unlike_track(headers: dict): response = requests.delete(f'{url_base}/me/tracks', headers=headers, params=track) error_handle('remove liked track', 200, 'DELETE', response=response) + +def update_playlist_details(name: str, description: str, playlist_id: str, headers: dict): + """ + Update the details of a playlist + :param playlist_id: id of the playlist + :param name: new name of the playlist + :param description: new description of the playlist + :param headers: request headers + :return: + """ + data = {'name': name, 'description': description} + response = requests.put(f'{url_base}/playlists/{playlist_id}', headers=headers, json=data) + error_handle('update playlist details', 200, 'PUT', response=response) + + +def replace_playlist_tracks(playlist_id: str, tracks: list, headers: dict): + """ + Remove the tracks from a playlist + :param tracks: list of track uris + :param playlist_id: id of the playlist + :param headers: request headers + :return: + """ + data = {'uris': tracks} + response = requests.put(f'{url_base}/playlists/{playlist_id}/tracks', headers=headers, json=data) + error_handle('remove tracks from playlist', 201, 'PUT', response=response) + + +def get_playlist(headers: dict, playlist_id: str): + """ + Retrieve playlist from API + :param headers: request headers + :param playlist_id: ID of the playlist + :return: playlist object + """ + response = requests.get(f'{url_base}/playlists/{playlist_id}', headers=headers) + error_handle('retrieve playlist', 200, 'GET', response=response) + return json.loads(response.content.decode('utf-8')) + + +def remove_from_playlist(tracks: list, playlist_id: str, headers: dict): + """ + Remove track(s) from a playlist + :param tracks: the tracks to remove + :param playlist_id: identifier of the playlist to remove tracks from + :param headers: request headers + """ + data = {'tracks': [{'uri': x} for x in tracks]} + response = requests.delete(f'{url_base}/playlists/{playlist_id}/tracks', headers=headers, json=data) + error_handle('delete track from playlist', 200, 'DELETE', response=response) + + +def get_audio_features(track_id: str, headers: dict) -> json: + """ + Get audio features of a track + :param track_id: id of the track + :param headers: request headers + :return: audio features object + """ + response = requests.get(f'{url_base}/audio-features/{track_id}', headers=headers) + error_handle('retrieve audio features', 200, 'GET', response=response) + return json.loads(response.content.decode('utf-8')) + + +def check_if_playlist_exists(playlist_id: str, headers: dict) -> bool: + """ + Checks whether a playlist exists + :param playlist_id: id of playlist + :param headers: request headers + :return: bool determining if playlist exists + """ + response = requests.get(f'{url_base}/playlists/{playlist_id}', headers=headers) + # If playlist is public, return true (if playlist has been deleted, this value is false) + if json.loads(response.content.decode('utf-8'))['public']: + return True + else: + print('Playlist has either been deleted, or made private, creating new...') + return False + + +def transfer_playback(device_id: str, headers: dict, start_playback=True): + """ + Transfer playback to device + :param device_id: id to transfer playback to + :param headers: request headers + :param start_playback: if music should start playing or not + """ + data = {'device_ids': [device_id], 'play': start_playback} + response = requests.put(f'{url_base}/me/player', headers=headers, json=data) + error_handle('transfer playback', 204, 'PUT', response=response) diff --git a/conf.py b/conf.py new file mode 100644 index 0000000..59f8f23 --- /dev/null +++ b/conf.py @@ -0,0 +1,283 @@ +import json +import re +import configparser +import ast +from pathlib import Path + + +CONFIG_DIR = f'{Path.home()}/.config/spotirec' +URI_RE = r'spotify:(artist|track):[a-zA-Z0-9]' + + +def open_config() -> configparser.ConfigParser: + """ + Open configuration file as object + :return: config object + """ + try: + # Read config and assert size + c = configparser.ConfigParser() + c.read_file(open(f'{CONFIG_DIR}/spotirec.conf')) + assert len(c.keys()) > 0 + return c + except (FileNotFoundError, AssertionError): + print('Config file not found, generating...') + # If config does not exist or is empty, convert old or create new and do recursive call + convert_or_create_config() + return open_config() + + +def save_config(c: configparser.ConfigParser): + """ + Write config to file + :param c: config object + """ + c.write(open(f'{CONFIG_DIR}/spotirec.conf', 'w')) + + +def convert_or_create_config(): + """ + Convert old config files to new. If old files do not exist, simply add the necessary sections. + """ + c = configparser.ConfigParser() + old_conf = ['spotirecoauth', 'presets', 'blacklist', 'devices', 'playlists'] + for x in old_conf: + # Add new section + c.add_section(x) + try: + with open(f'{CONFIG_DIR}/{x}', 'r') as f: + # Set each configuration to section + for y in json.loads(f.read()).items(): + c.set(x, y[0], str(y[1])) + except (FileNotFoundError, json.JSONDecodeError): + # If file isn't found or is empty, pass and leave section empty + pass + print('Done') + print('If you have the old style config files you may safely delete these, or save them as backup') + save_config(c) + + +def get_oauth() -> dict: + """ + Retrieve OAuth section from config + :return: OAuth section as dict + """ + c = open_config() + try: + return c['spotirecoauth'] + except KeyError: + c.add_section('spotirecoauth') + save_config(c) + return c['spotirecoauth'] + + +def get_blacklist() -> dict: + """ + Retrieve blacklist section from config + :return: blacklist section as dict + """ + c = open_config() + try: + blacklist = {} + for x in c['blacklist'].items(): + # Parse each blacklist entry as dict + blacklist[x[0]] = ast.literal_eval(x[1]) + return blacklist + except KeyError: + c.add_section('blacklist') + save_config(c) + return c['blacklist'] + + +def add_to_blacklist(uri_data: json, uri: str): + """ + Add entry to blacklist + :param uri_data: data regarding blacklist entry retrieved from API + :param uri: URI of blacklist entry + :return: + """ + uri_type = uri.split(':')[1] + # Convert entry to dict + data = {'name': uri_data['name'], 'uri': uri} + try: + data['artists'] = [x['name'] for x in uri_data['artists']] + except KeyError: + pass + c = open_config() + print(f'Adding {uri_type} {data["name"]} to blacklist') + # Get the blacklist type entry from config and parse as dict, and add entry + blacklist = ast.literal_eval(c.get('blacklist', f'{uri_type}s')) + blacklist[uri] = data + c.set('blacklist', f'{uri_type}s', str(blacklist)) + save_config(c) + + +def remove_from_blacklist(uri: str): + """ + Remove entry from blacklsit + :param uri: + :return: + """ + # Ensure input is valid + if not re.match(URI_RE, uri): + print(f'Error: uri {uri} is not a valid uri') + return + c = open_config() + uri_type = uri.split(':')[1] + # Ensure entry exists and delete if so + try: + blacklist = ast.literal_eval(c.get('blacklist', f'{uri_type}s')) + print(f'Removing {uri_type} {blacklist[uri]["name"]} from blacklist') + del blacklist[uri] + c.set('blacklist', f'{uri_type}s', str(blacklist)) + except KeyError: + print(f'Error: {uri_type} {uri} does not exist in blacklist') + save_config(c) + + +def get_presets() -> dict: + """ + Retrieve preset section from config + :return: preset section as dict + """ + c = open_config() + try: + presets = {} + for x in c['presets'].items(): + presets[x[0]] = ast.literal_eval(x[1]) + return presets + except KeyError: + c.add_section('presets') + save_config(c) + return c['presets'] + + +def save_preset(preset: dict, preset_id: str): + """ + Add entry to presets + :param preset: preset data + :param preset_id: identifier of new preset + """ + c = open_config() + try: + c['presets'] + except KeyError: + c.add_section('presets') + c.set('presets', preset_id, str(preset)) + print(f'Added preset {preset_id} to config') + save_config(c) + + +def remove_preset(iden: str): + """ + Remove entry from presets + :param iden: identifier of preset to remove + :return: + """ + c = open_config() + try: + c.remove_option('presets', iden) + print(f'Deleted preset {iden} from config') + save_config(c) + except KeyError: + print(f'Error: preset {iden} does not exist in config') + + +def get_devices() -> dict: + """ + Retrieve device section from config + :return: device section as dict + """ + c = open_config() + try: + devices = {} + for x in c['devices'].items(): + # Parse each preset entry as dict + devices[x[0]] = ast.literal_eval(x[1]) + return devices + except KeyError: + c.add_section('devices') + save_config(c) + return c['devices'] + + +def save_device(device: dict, device_id: str): + """ + Add entry to devices + :param device: device data + :param device_id: identifier of the new device + :return: + """ + c = open_config() + try: + c['devices'] + except KeyError: + c.add_section('devices') + c.set('devices', device_id, str(device)) + print(f'Added device {device_id} to config') + save_config(c) + + +def remove_device(iden: str): + """ + Remove entry from devices + :param iden: identifier of the device to remove + :return: + """ + c = open_config() + try: + c.remove_option('devices', iden) + print(f'Deleted device {iden} from config') + save_config(c) + except KeyError: + print(f'Error: device {iden} does not exist in config') + + +def get_playlists() -> dict: + """ + Retrieve playlist section from config + :return: playlist section as dict + """ + c = open_config() + try: + playlists = {} + for x in c['playlists'].items(): + # Parse each playlist entry as dict + playlists[x[0]] = ast.literal_eval(x[1]) + return playlists + except KeyError: + c.add_section('playlists') + save_config(c) + return c['playlists'] + + +def save_playlist(playlist: dict, playlist_id: str): + """ + Add entry to playlists + :param playlist: playlist data + :param playlist_id: identifier of the new playlist + :return: + """ + c = open_config() + try: + c['playlists'] + except KeyError: + c.add_section('playlists') + c.set('playlists', playlist_id, str(playlist)) + print(f'Added playlist {playlist_id} to config') + save_config(c) + + +def remove_playlist(iden: str): + """ + Remove entry from playlists + :param iden: identifier of the playlist to remove + :return: + """ + c = open_config() + try: + c.remove_option('playlists', iden) + print(f'Deleted playlist {iden} from config') + save_config(c) + except KeyError: + print(f'Error: playlist {iden} does not exist in config') diff --git a/oauth2.py b/oauth2.py index 30fb003..84638d8 100644 --- a/oauth2.py +++ b/oauth2.py @@ -4,8 +4,8 @@ import requests import base64 import api +import conf from urllib import parse -from pathlib import Path class SpotifyOAuth: @@ -20,21 +20,19 @@ def __init__(self): self.scopes = 'user-top-read playlist-modify-public playlist-modify-private user-read-private ' \ 'user-read-email ugc-image-upload user-read-playback-state user-modify-playback-state ' \ 'user-library-modify' - self.cache = f'{Path.home()}/.config/spotirec/spotirecoauth' def get_credentials(self) -> json: """ Get credentials from cache file. Refresh token if it's about to expire. - :return: token contents as a json object + :return: token contents as a config object """ try: - with open(self.cache, 'r') as file: - creds = json.loads(file.read()) - if self.is_token_expired(creds['expires_at']): - print('OAuth token is expired, refreshing...') - creds = self.refresh_token(creds['refresh_token']) + creds = conf.get_oauth() + if self.is_token_expired(int(creds['expires_at'])): + print('OAuth token is expired, refreshing...') + creds = self.refresh_token(creds['refresh_token']) except (IOError, json.decoder.JSONDecodeError): - print('Error: cache does not exist or is empty') + print('Error: OAuth config does not exist or is empty') return None return creds @@ -112,12 +110,14 @@ def parse_response_code(self, url: str) -> str: def save_token(self, token: json, refresh_token=None): """ - Add 'expires at' field and reapplies refresh token to token, and save to cache - :param token: credentials as a json object + Add 'expires at' field and reapplies refresh token to token, and save to config + :param token: credentials as a config object :param refresh_token: user refresh token """ token['expires_at'] = round(time.time()) + int(token['expires_in']) if refresh_token: token['refresh_token'] = refresh_token - with open(self.cache, 'w') as file: - file.write(json.dumps(token)) + c = conf.open_config() + for x in token.items(): + c['spotirecoauth'][x[0]] = str(x[1]) + conf.save_config(c) diff --git a/spotirec.py b/spotirec.py index 7da7aa7..209bef1 100644 --- a/spotirec.py +++ b/spotirec.py @@ -11,34 +11,53 @@ import oauth2 import recommendation import api +import conf +import sys from io import BytesIO from PIL import Image from bottle import route, run, request from pathlib import Path -port = 8080 -config_path = f'{Path.home()}/.config/spotirec' -blacklist_path = f'{config_path}/blacklist' -preset_path = f'{config_path}/presets' -devices_path = f'{config_path}/devices' -tune_prefix = ['max', 'min', 'target'] -tune_attr = ['acousticness', 'danceability', 'duration_ms', 'energy', 'instrumentalness', 'key', 'liveness', - 'loudness', 'mode', 'popularity', 'speechiness', 'tempo', 'time_signature', 'valence', 'popularity'] -uri_re = r'spotify:(artist|track):[a-zA-Z0-9]' + +VERSION = '1.2' + +PORT = 8080 +CONFIG_PATH = f'{Path.home()}/.config/spotirec' + +TUNE_PREFIX = ['max', 'min', 'target'] +TUNE_ATTR = {'int': {'duration_ms': {'min': 0, 'max': sys.maxsize * 2 + 1, 'rec_min': 0, 'rec_max': 3600000}, + 'key': {'min': 0, 'max': 11, 'rec_min': 0, 'rec_max': 11}, + 'mode': {'min': 0, 'max': 1, 'rec_min': 0, 'rec_max': 1}, + 'time_signature': {'min': 0, 'max': 500, 'rec_min': 0, 'rec_max': 500}, + 'popularity': {'min': 0, 'max': 100, 'rec_min': 0, 'rec_max': 100}}, + 'float': {'acousticness': {'min': 0.0, 'max': 1.0, 'rec_min': 0.0, 'rec_max': 1.0}, + 'danceability': {'min': 0.0, 'max': 1.0, 'rec_min': 0.1, 'rec_max': 0.9}, + 'energy': {'min': 0.0, 'max': 1.0, 'rec_min': 0.0, 'rec_max': 1.0}, + 'instrumentalness': {'min': 0.0, 'max': 1.0, 'rec_min': 0.0, 'rec_max': 1.0}, + 'liveness': {'min': 0.0, 'max': 1.0, 'rec_min': 0.0, 'rec_max': 0.4}, + 'loudness': {'min': -60, 'max': 0, 'rec_min': -20, 'rec_max': 0}, + 'speechiness': {'min': 0.0, 'max': 1.0, 'rec_min': 0.0, 'rec_max': 0.3}, + 'valence': {'min': 0.0, 'max': 1.0, 'rec_min': 0.0, 'rec_max': 1.0}, + 'tempo': {'min': 0.0, 'max': 220.0, 'rec_min': 60.0, 'rec_max': 210.0}}} +URI_RE = r'spotify:(artist|track):[a-zA-Z0-9]+' +PLAYLIST_URI_RE = r'spotify:playlist:[a-zA-Z0-9]+' +TRACK_URI_RE = r'spotify:track:[a-zA-Z0-9]+' # OAuth handler sp_oauth = oauth2.SpotifyOAuth() # Argument parser -parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, +parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, prog='spotirec', epilog=""" passing no recommendation scheme argument defaults to basing recommendations off your top 5 valid seed genres spotirec is released under GPL-3.0 and comes with ABSOLUTELY NO WARRANTY, for details read LICENSE""") parser.add_argument('n', nargs='?', type=int, const=5, default=5, help='amount of seeds to use on no-arg recommendations as an integer - note that this must appear ' 'as the first argument if used and can only be used with no-arg') -# Create mutually exclusive group for recommendation types to ensure only one is given + +# Recommendation schemes rec_scheme_group = parser.add_argument_group(title='Recommendation schemes') +# Create mutually exclusive group for recommendation types to ensure only one is given mutex_group = rec_scheme_group.add_mutually_exclusive_group() mutex_group.add_argument('-a', metavar='SEED_SIZE', nargs='?', type=int, const=5, choices=range(1, 6), help='base recommendations on your top artists') @@ -49,53 +68,62 @@ mutex_group.add_argument('-gc', action='store_true', help='base recommendations on custom top valid seed genres') mutex_group.add_argument('-gcs', action='store_true', help='base recommendations on custom seed genres') mutex_group.add_argument('-c', action='store_true', help='base recommendations on a custom seed') +rec_scheme_group.add_argument('--preserve', action='store_true', help='preserve previous playlist and create new') +# Saving arguments save_group = parser.add_argument_group(title='Saving arguments') +# You should only be able to save or remove the current track at once, not both save_mutex_group = save_group.add_mutually_exclusive_group() +add_mutex_group = save_group.add_mutually_exclusive_group() save_mutex_group.add_argument('-s', action='store_true', help='like currently playing track') save_mutex_group.add_argument('-sr', action='store_true', help='remove currently playing track from liked tracks') - +add_mutex_group.add_argument('--add-to', metavar='[PLAYLIST | URI]', nargs=1, type=str, + help='add currently playing track to input playlist') +add_mutex_group.add_argument('--remove-from', metavar='[PLAYLIST | URI]', nargs=1, type=str, + help='remove currently playing track from input playlist') +save_group.add_argument('--save-playlist', action='store_true', help='save a playlist') +save_group.add_argument('--remove-playlists', metavar='ID', nargs='+', type=str, help='remove playlist(s)') +save_group.add_argument('--save-device', action='store_true', help='save a playback device') +save_group.add_argument('--remove-devices', metavar='ID', nargs='+', type=str, help='remove playback device(s)') +save_group.add_argument('--remove-presets', metavar='ID', nargs='+', type=str, help='remove preset(s)') + +# Recommendation modifications rec_options_group = parser.add_argument_group(title='Recommendation options', description='These may only appear when creating a playlist') rec_options_group.add_argument('-l', metavar='LIMIT', nargs=1, type=int, choices=range(1, 101), help='amount of tracks to add (default: 20, max: 100)') -preset_mutex = rec_options_group.add_mutually_exclusive_group() -preset_mutex.add_argument('-p', metavar='NAME', nargs=1, type=str, help='load and use preset') -preset_mutex.add_argument('-ps', metavar='NAME', nargs=1, type=str, help='save options as preset') rec_options_group.add_argument('--tune', metavar='ATTR', nargs='+', type=str, help='specify tunable attribute(s)') -play_mutex = rec_options_group.add_mutually_exclusive_group() -play_mutex.add_argument('--play', action='store_true', help='select playback device to start playing on') -play_mutex.add_argument('--play-device', metavar='DEVICE', nargs=1, type=str, help='start playback on saved device') - -device_group = parser.add_argument_group(title='Playback devices') -device_group.add_argument('-d', action='store_true', help='save a device') -device_group.add_argument('-dr', metavar='DEVICE', nargs="+", type=str, help='remove device(s)') +rec_options_group.add_argument('--play', metavar='DEVICE', nargs=1, help='select playback device to start playing on') +rec_options_group.add_argument('--load-preset', metavar='ID', nargs=1, type=str, help='load and use preset') +rec_options_group.add_argument('--save-preset', metavar='ID', nargs=1, type=str, help='save options as preset') +# Blacklisting blacklist_group = parser.add_argument_group(title='Blacklisting') blacklist_group.add_argument('-b', metavar='URI', nargs='+', type=str, help='blacklist track(s) and/or artist(s)') blacklist_group.add_argument('-br', metavar='URI', nargs='+', type=str, help='remove track(s) and/or artists(s) from blacklist') -blacklist_group.add_argument('-bc', metavar='artist|track', nargs=1, choices=['artist', 'track'], +blacklist_group.add_argument('-bc', metavar='artist | track', nargs=1, choices=['artist', 'track'], help='blacklist currently playing artist(s) or track') +# Playback +playback_group = parser.add_argument_group(title='Playback') +playback_group.add_argument('--transfer-playback', metavar='ID', nargs=1, type=str, + help='transfer playback to input device ID') + +# Printing print_group = parser.add_argument_group(title='Printing') print_group.add_argument('--print', metavar='TYPE', nargs=1, type=str, - choices=['artists', 'tracks', 'genres', 'genre-seeds', 'devices', 'blacklist'], - help='print a list of genre seeds, or your top artists, tracks, or genres, where' - 'TYPE=[artists|tracks|genres|genre-seeds|devices|blacklist]') - -# Ensure config dir and blacklist file exists -if not os.path.isdir(config_path): - os.makedirs(config_path) -if not os.path.exists(blacklist_path): - f = open(blacklist_path, 'w') - f.close() -if not os.path.exists(preset_path): - f = open(preset_path, 'w') - f.close() -if not os.path.exists(devices_path): - f = open(devices_path, 'w') - f.close() + choices=['artists', 'tracks', 'genres', 'genre-seeds', 'devices', 'blacklist', 'presets', + 'playlists', 'tuning'], + help='print a list of genre seeds, or your top artists, tracks, or genres, where ' + 'TYPE=[artists|tracks|genres|genre-seeds|devices|blacklist|presets|playlists|tuning]') +print_group.add_argument('--version', action='version', version=f'%(prog)s v{VERSION}') +print_group.add_argument('--track-features', metavar='[URI | current]', nargs=1, type=str, + help='print track features of URI or currently playing track') + +# Ensure config dir exists +if not os.path.isdir(CONFIG_PATH): + os.makedirs(CONFIG_PATH) def authorize(): @@ -104,7 +132,7 @@ def authorize(): Function index() will be routed on said http server. """ webbrowser.open(sp_oauth.redirect) - run(host='', port=port) + run(host='', port=PORT) @route('/') @@ -132,7 +160,7 @@ def get_token() -> str: """ creds = sp_oauth.get_credentials() if creds: - return creds['access_token'] + return creds.get('access_token') else: authorize() exit(1) @@ -143,16 +171,18 @@ def get_user_top_genres() -> dict: Extract genres from user's top 50 artists and map them to their amount of occurrences :return: dict of genres and their count of occurrences """ - data = api.get_top_list('artists', 50, headers=headers) + data = api.get_top_list('artists', 50, headers) genres = {} - genre_seeds = api.get_genre_seeds(headers=headers) + genre_seeds = api.get_genre_seeds(headers) + # Loop through each genre of each artist for x in data['items']: for genre in x['genres']: genre = genre.replace(' ', '-') - if genre in genre_seeds['genres']: - if genre in genres.keys(): + # Check if genre is a valid seed + if any(g == genre for g in genre_seeds['genres']): + try: genres[genre] += 1 - else: + except KeyError: genres[genre] = 1 return genres @@ -178,6 +208,7 @@ def print_choices(data=None, prompt=True, sort=False) -> str: sorted_data = sorted(data.items(), key=lambda kv: kv[1], reverse=True) data = [sorted_data[x][0] for x in range(0, len(sorted_data))] line = "" + # Format output lines, three seeds per line for x in range(0, round(len(data)), 3): try: line += f'{x}: {data[x]}' @@ -191,12 +222,16 @@ def print_choices(data=None, prompt=True, sort=False) -> str: continue print(line.strip('\n')) if prompt: - input_string = input('Enter integer identifiers for 1-5 whitespace separated selections that you wish to ' - 'include [default: top 5]:\n') or '0 1 2 3 4' + try: + input_string = input('Enter integer identifiers for 1-5 whitespace separated selections that you wish to ' + 'include [default: top 5]:\n') or '0 1 2 3 4' + except KeyboardInterrupt: + exit(0) + # If seed type is genres, simply parse the seed, else return the input for further processing if 'genres' in rec.seed_type: - parse_seed_info([data[int(x)] for x in input_string.split(' ')]) + parse_seed_info([data[int(x)] for x in input_string.strip(' ').split(' ')]) else: - return input_string + return input_string.strip(' ') def print_artists_or_tracks(data: json, prompt=True): @@ -220,9 +255,8 @@ def check_if_valid_genre(genre: str) -> bool: :param genre: user input genre :return: True if genre exists, False if not """ - if genre in get_user_top_genres(): - return True - if genre in api.get_genre_seeds(headers=headers)['genres']: + if any(g == genre for g in get_user_top_genres()) or any( + g == genre for g in api.get_genre_seeds(headers)['genres']): return True return False @@ -232,18 +266,36 @@ def check_tune_validity(tune: str): Check validity of tune input - exit program if not valid :param tune: tune input as string """ - if not tune.split('_', 1)[0] in tune_prefix: + prefix = tune.split('_', 1)[0] + key = tune.split('=')[0].split('_', 1)[1] + value = tune.split('=')[1] + # Check prefix validity + if prefix not in TUNE_PREFIX: print(f'Tune prefix \"{tune.split("_", 1)[0]}\" is malformed - available prefixes:') - print(tune_prefix) + print(TUNE_PREFIX) exit(1) - if not tune.split('=')[0].split('_', 1)[1] in tune_attr: + # Check attribute validity + if key not in list(TUNE_ATTR['int'].keys()) + list(TUNE_ATTR['float'].keys()): print(f'Tune attribute \"{tune.split("=")[0].split("_", 1)[1]}\" is malformed - available attributes:') - print(tune_attr) + print(list(TUNE_ATTR['int'].keys()) + list(TUNE_ATTR['float'].keys())) exit(1) + # Try parsing value to number try: - float(tune.split('=')[1]) if '.' in tune.split('=')[1] else int(tune.split('=')[1]) + # Try parsing value to number + value = int(float(value)) if key in TUNE_ATTR['int'].keys() else float(value) + value_type = 'int' if key in TUNE_ATTR['int'].keys() else 'float' + # Ensure value is within accepted range + if not TUNE_ATTR[value_type][key]['max'] >= value >= TUNE_ATTR[value_type][key]['min']: + print(f'Error: value {value} for attribute {key} is outside the accepted range (min: ' + f'{TUNE_ATTR[value_type][key]["min"]}, max: {TUNE_ATTR[value_type][key]["max"]})') + exit(1) + # Warn if value is outside recommended range + if not TUNE_ATTR[value_type][key]['rec_max'] >= value >= TUNE_ATTR[value_type][key]['rec_min']: + print(f'Warning: value {value} for attribute {key} is outside the recommended range (min: ' + f'{TUNE_ATTR[value_type][key]["rec_min"]}, max: {TUNE_ATTR[value_type][key]["rec_max"]}), ' + f'recommendations may be scarce') except ValueError: - print(f'Tune value {tune.split("=")[1]} is not a valid integer or float value') + print(f'Tune value {value} does not match attribute {key} data type requirements') exit(1) @@ -255,14 +307,15 @@ def parse_seed_info(seeds): if len(shlex.split(seeds) if type(seeds) is str else seeds) > 5: print('Please enter at most 5 seeds') exit(1) + # Parse each seed in input and add to seed string depending on type for x in shlex.split(seeds) if type(seeds) is str else seeds: if rec.seed_type == 'genres': rec.add_seed_info(data_string=x) elif rec.seed_type == 'custom': if check_if_valid_genre(x): rec.add_seed_info(data_string=x) - elif re.match(uri_re, x): - rec.add_seed_info(data_dict=api.request_data(x, f'{x.split(":")[1]}s', headers=headers)) + elif re.match(URI_RE, x): + rec.add_seed_info(data_dict=api.request_data(x, f'{x.split(":")[1]}s', headers)) else: print(f'Input \"{x}\" does not match a genre or a valid URI syntax, skipping...') else: @@ -274,28 +327,9 @@ def add_to_blacklist(entries: list): Add input uris to blacklist and exit :param entries: list of input uris """ - with open(blacklist_path, 'r') as file: - try: - data = json.loads(file.read()) - except json.decoder.JSONDecodeError: - data = {'tracks': {}, - 'artists': {}} - for uri in entries: - if re.match(uri_re, uri): - uri_data = api.request_data(uri, f'{uri.split(":")[1]}s', headers=headers) - data[f'{uri.split(":")[1]}s'][uri] = {'name': uri_data['name'], - 'uri': uri} - try: - data[f'{uri.split(":")[1]}s'][uri]['artists'] = [x['name'] for x in uri_data['artists']] - print(f'Added track \"{uri_data["name"]}\" by ' - f'{", ".join(str(x["name"]) for x in uri_data["artists"])} to your blacklist') - except KeyError: - print(f'Added artist \"{uri_data["name"]}\" to your blacklist') - else: - print(f'uri \"{uri}\" is either not a valid uri for a track or artist, or is malformed and has ' - f'not been added to the blacklist') - with open(blacklist_path, 'w+') as file: - file.write(json.dumps(data)) + for x in entries: + uri_data = api.request_data(x, f'{x.split(":")[1]}s', headers) + conf.add_to_blacklist(uri_data, x) def remove_from_blacklist(entries: list): @@ -303,50 +337,21 @@ def remove_from_blacklist(entries: list): Remove track(s) and/or artist(s) from blacklist. :param entries: list of uris """ - try: - with open(blacklist_path, 'r') as file: - blacklist = json.loads(file.read()) - except json.decoder.JSONDecodeError: - print('Error: blacklist is empty') - exit(1) - for uri in entries: - if re.match(uri_re, uri): - try: - try: - print(f'Removing track {blacklist["tracks"][uri]["name"]} by ' - f'{", ".join(str(x) for x in blacklist["tracks"][uri]["artists"]).strip(", ")} from blacklist') - except KeyError: - print(f'Removing artist \"{blacklist["artists"][uri]["name"]}\" from blacklist') - del blacklist[f'{uri.split(":")[1]}s'][uri] - except KeyError: - print(f'uri \"{uri}\" does not exist in your blacklist') - # FIXME: Remove this notice at some point - print('Blacklist structure was recently re-done, so you may need to remove and re-do your blacklist. ' - 'Sorry!') - else: - print(f'uri \"{uri}\" is either not a valid uri for a track or artist or is malformed') - - with open(blacklist_path, 'w+') as file: - file.write(json.dumps(blacklist)) + for x in entries: + conf.remove_from_blacklist(x) def print_blacklist(): """ Format and print blacklist entries """ - with open(blacklist_path, 'r') as file: - try: - blacklist = json.loads(file.read()) - print('Tracks') - print('--------------------------') - for x in blacklist['tracks'].values(): - print(f'{x["name"]} by {", ".join(x["artists"])} - {x["uri"]}') - print('\nArtists') - print('--------------------------') - for x in blacklist['artists'].values(): - print(f'{x["name"]} - {x["uri"]}') - except json.decoder.JSONDecodeError: - print('Blacklist is empty') + blacklist = conf.get_blacklist() + print('\033[1m' + 'Tracks' + '\033[0m') + for x in blacklist.get('tracks').values(): + print(f'{x["name"]} by {", ".join(x["artists"])} - {x["uri"]}') + print('\n' + '\033[1m' + 'Artists' + '\033[0m') + for x in blacklist.get('artists').values(): + print(f'{x["name"]} - {x["uri"]}') def generate_img(tracks: list) -> Image: @@ -356,15 +361,21 @@ def generate_img(tracks: list) -> Image: :param tracks: list of track uris :return: a 320x320 image generated from playlist hash """ + # Hash tracks to a playlist-unique string track_hash = hashlib.sha256(''.join(str(x) for x in tracks).encode('utf-8')).hexdigest() + # Use the first six chars of the hash to generate a color + # The hex value of three pairs of chars are converted to integers, yielding a list on the form [r, g, b] color = [int(track_hash[i:i + 2], 16) for i in (0, 2, 4)] + # Create an image object the size of the squared square root of the hash string - always 8x8 img = Image.new('RGB', (int(math.sqrt(len(track_hash))), int(math.sqrt(len(track_hash))))) pixel_map = [] + # Iterate over hash string and assign to pixel map each digit to the generated color, each letter to light gray for x in track_hash: if re.match(r'[0-9]', x): pixel_map.append(color) else: pixel_map.append([200, 200, 200]) + # Add the pixel map to the image object and return as a size suited for the Spotify API img.putdata([tuple(x) for x in pixel_map]) return img.resize((320, 320), Image.AFFINE) @@ -380,7 +391,7 @@ def add_image_to_playlist(tracks: list): img_buffer = BytesIO() generate_img(tracks).save(img_buffer, format='JPEG') img_str = base64.b64encode(img_buffer.getvalue()) - api.upload_image(playlist_id=rec.playlist_id, data=img_str, img_headers=img_headers) + api.upload_image(rec.playlist_id, img_str, img_headers) def save_preset(name: str): @@ -388,22 +399,15 @@ def save_preset(name: str): Save recommendation object as preset :param name: name of preset """ - try: - with open(preset_path, 'r') as file: - preset_data = json.loads(file.read()) - except json.decoder.JSONDecodeError: - preset_data = {} - preset_data[name] = {'limit': rec.limit_original, - 'based_on': rec.based_on, - 'seed': rec.seed, - 'seed_type': rec.seed_type, - 'seed_info': rec.seed_info, - 'rec_params': rec.rec_params, - 'auto_play': rec.auto_play, - 'playback_device': rec.playback_device} - with open(preset_path, 'w+') as file: - print(f'Saving preset \"{name}\"') - file.write(json.dumps(preset_data)) + preset = {'limit': rec.limit_original, + 'based_on': rec.based_on, + 'seed': rec.seed, + 'seed_type': rec.seed_type, + 'seed_info': rec.seed_info, + 'rec_params': rec.rec_params, + 'auto_play': rec.auto_play, + 'playback_device': rec.playback_device} + conf.save_preset(preset, name) def load_preset(name: str) -> recommendation.Recommendation: @@ -413,83 +417,58 @@ def load_preset(name: str) -> recommendation.Recommendation: :return: recommendation object with settings from preset """ print(f'Using preset \"{name}\"') + presets = conf.get_presets() try: - with open(preset_path, 'r') as file: - preset_data = json.loads(file.read()) - except json.decoder.JSONDecodeError: - print('Error: you do not have any presets') - exit(1) - try: - contents = preset_data[name] - preset = recommendation.Recommendation() - preset.limit = contents['limit'] - preset.limit_original = contents['limit'] - preset.based_on = contents['based_on'] - preset.seed = contents['seed'] - preset.seed_type = contents['seed_type'] - preset.seed_info = contents['seed_info'] - preset.rec_params = contents['rec_params'] - preset.auto_play = contents['auto_play'] - preset.playback_device = contents['playback_device'] - return preset + contents = presets.get(name) except KeyError: print(f'Error: could not find preset \"{name}\", check spelling and try again') exit(1) + preset = recommendation.Recommendation() + preset.limit = contents['limit'] + preset.limit_original = contents['limit'] + preset.based_on = contents['based_on'] + preset.seed = contents['seed'] + preset.seed_type = contents['seed_type'] + preset.seed_info = contents['seed_info'] + preset.rec_params = contents['rec_params'] + preset.auto_play = contents['auto_play'] + preset.playback_device = contents['playback_device'] + return preset -def get_device(device_name: str): +def remove_presets(presets: list): """ - Set playback device. Prompt from available devices if none exist. - Print saved devices if it does not exist. - :param device_name: name of playback device + Remove preset(s) from user config + :param presets: list of devices """ - try: - with open(devices_path, 'r') as file: - devices = json.loads(file.read()) - try: - rec.playback_device = devices[device_name] - return - except KeyError: - print(f'Error: device \"{device_name}\" not recognized. Saved devices:') - for x in devices: - print(x) - exit(1) - except json.decoder.JSONDecodeError: - print('Your device list is empty') - print_devices() + for x in presets: + conf.remove_preset(x) -def print_devices(save_prompt=True, selection_prompt=True): +def print_presets(): """ - Print available devices and prompt user to select depending on params - :param save_prompt: whether or not user should be prompted if they want to save - :param selection_prompt: whether or not user should be prompted for a selection + Format and print preset entries """ - devices = api.get_available_devices(headers)['devices'] - print('Available devices:') - print(f'Name{" " * 19}Type') - print("-" * 40) - for x in devices: - print(f'{devices.index(x)}. {x["name"]}{" " * (20 - len(x["name"]))}{x["type"]}') - if selection_prompt: - def prompt_selection() -> int: - try: - inp = int(input(f'Please select a device by index number [default: ' - f'{devices[0]["name"]}]: ') or 0) - assert devices[inp] is not None - return inp - except (IndexError, AssertionError, ValueError): - print('Error: please input a valid index number') - return prompt_selection() - - selection = prompt_selection() - rec.playback_device = {'name': devices[selection]['name'], - 'id': devices[selection]['id'], - 'type': devices[selection]['type']} - save = input(f'Would you like to save \"{rec.playback_device["name"]}\" ' - 'for later use? [y/n] ') or 'y' if save_prompt else save_device() - if save == 'y': - save_device() + presets = conf.get_presets() + print('\033[1m' + f'Name{" " * 16}Type{" " * 21}Params{" " * 44}Seeds' + '\033[0m') + for x in presets.items(): + params = ",".join(f"{y[0]}={y[1]}" if "seed" not in y[0] else "" for y in x[1]["rec_params"].items()).strip(',') + print(f'{x[0]}{" " * (20 - len(x[0]))}{x[1]["based_on"]}{" " * (25 - len(x[1]["based_on"]))}' + f'{params}{" " * (50 - len(params))}{",".join(str(y["name"]) for y in x[1]["seed_info"].values())}') + + +def get_device(device_name: str) -> dict: + """ + Set playback device. Prompt from available devices if none exist. + Print saved devices if it does not exist. + :param device_name: name of playback device + """ + devices = conf.get_devices() + try: + return devices.get(device_name) + except KeyError: + print(f'Error: device {device_name} does not exist in config') + exit(1) def save_device(): @@ -497,64 +476,218 @@ def save_device(): Prompt user for an identifier for device and save to config """ + def prompt_device_index() -> int: + try: + ind = input('Select a device by index[0]: ') or 0 + except KeyboardInterrupt: + exit(0) + try: + assert devices[int(ind)] is not None + return int(ind) + except (ValueError, AssertionError, IndexError): + print(f'Error: input \"{ind}\" is malformed.') + print('Please ensure that your input is an integer and is a valid index.') + return prompt_device_index() + def prompt_name() -> str: try: - inp = input('Enter an identifier for your device: ') + try: + inp = input('Enter an identifier for your device: ') + except KeyboardInterrupt: + exit(0) assert inp + assert ' ' not in inp return inp except AssertionError: - prompt_name() + print(f'Error: device identifier \"{inp}\" is malformed.') + print('Please ensure that the identifier contains at least one character, and no whitespaces.') + return prompt_name() - name = prompt_name().replace(' ', '') - try: - with open(devices_path, 'r') as file: - devices = json.loads(file.read()) - except json.decoder.JSONDecodeError: - devices = {} - devices[name] = rec.playback_device - with open(devices_path, 'w+') as file: - file.write(json.dumps(devices)) - print(f'Saved device \"{rec.playback_device["name"]}\" as \"{name}\"') + # Get available devices from API and print + devices = api.get_available_devices(headers)['devices'] + print('Available devices:') + print('\033[1m' + f'Name{" " * 19}Type' + '\033[0m') + for x in devices: + print(f'{devices.index(x)}. {x["name"]}{" " * (20 - len(x["name"]))}{x["type"]}') + print('Please note that a player needs to be active to be shown in the above list, i.e. if you want to save your ' + 'phone as a device, the app needs to be launched on your phone') + # Prompt device selection and identifier, and save to config + device = devices[prompt_device_index()] + device_dict = {'id': device['id'], 'name': device['name'], 'type': device['type']} + name = prompt_name() + conf.save_device(device_dict, name) def remove_devices(devices: list): """ Remove device(s) from user config - :param devices: list of devices + :param devices: list of device(s) """ - try: - with open(devices_path, 'r') as file: - saved_devices = json.loads(file.read()) - except json.decoder.JSONDecodeError: - print('You have no saved devices') - exit(1) for x in devices: - try: - del saved_devices[x] - print(f'Deleted device \"{x}\"') - except KeyError: - print(f'Could not find device \"{x}\" in saved devices') - pass - with open(devices_path, 'w+') as file: - file.write(json.dumps(saved_devices)) + conf.remove_device(x) def print_saved_devices(): """ Print all saved devices """ + devices = conf.get_devices() + print('\033[1m' + f'ID{" " * 18}Name{" " * 16}Type' + '\033[0m') + for x in devices.items(): + print(f'{x[0]}{" " * (20 - len(x[0]))}{x[1]["name"]}' + f'{" " * (20 - len(x[1]["name"]))}{x[1]["type"]}') + + +def print_playlists(): + """ + Print all saved playlists + """ + playlists = conf.get_playlists() + print('\033[1m' + f'ID{" " * 18}Name{" " * 26}URI' + '\033[0m') + for x in playlists.items(): + print(f'{x[0]}{" " * (20 - len(x[0]))}{x[1]["name"]}{" " * (30 - len(x[1]["name"]))}{x[1]["uri"]}') + + +def save_playlist(): + """ + Prompt user for an identifier and URI for playlist and save to config + """ + + def input_id() -> str: + try: + iden = input('Please input an identifier for your playlist: ') + except KeyboardInterrupt: + exit(0) + try: + assert iden + assert ' ' not in iden + return iden + except AssertionError: + print(f'Error: playlist identifier \"{iden}\" is malformed.') + print('Please ensure that the identifier contains at least one character, and no whitespaces.') + return input_id() + + def input_uri() -> str: + try: + uri = input('Please input the URI for your playlist: ') + except KeyboardInterrupt: + exit(0) + try: + assert uri + assert re.match(PLAYLIST_URI_RE, uri) + return uri + except AssertionError: + print(f'Error: playlist uri \"{uri}\" is malformed.') + return input_uri() + + # Prompt device identifier and URI, and save to config + playlist_id = input_id() + playlist_uri = input_uri() + playlist = {'name': api.get_playlist(headers, playlist_uri.split(':')[2])["name"], 'uri': playlist_uri} + conf.save_playlist(playlist, playlist_id) + + +def remove_playlists(playlists: list): + """ + Remove playlist(s) from user config + :param playlists: list of playlist(s) + """ + for x in playlists: + conf.remove_playlist(x) + + +def add_current_track(playlist: str): + """ + Add currently playing track to input playlist + :param playlist: identifier or URI for playlist + """ + # Check whether input is URI or identifier + if re.match(PLAYLIST_URI_RE, playlist): + playlist_id = playlist.split(':')[2] + else: + playlists = conf.get_playlists() + try: + playlist_id = playlists[playlist]['uri'].split(':')[2] + except KeyError: + print(f'Error: playlist {playlist} does not exist in config') + exit(1) + print(f'Adding currently playing track to playlist') + api.add_to_playlist([api.get_current_track(headers)], playlist_id, headers) + + +def remove_current_track(playlist: str): + """ + Remove currently playing track from input playlist + :param playlist: identifier or URI for playlist + """ + # Check whether input is URI or identifier + if re.match(PLAYLIST_URI_RE, playlist): + playlist_id = playlist.split(':')[2] + else: + playlists = conf.get_playlists() + try: + playlist_id = playlists[playlist]['uri'].split(':')[2] + except KeyError: + print(f'Error: playlist {playlist} does not exist in config') + exit(1) + print(f'Removing currently playing track to playlist') + api.remove_from_playlist([api.get_current_track(headers)], playlist_id, headers) + + +def print_track_features(uri: str): + """ + Prints various information about a track + :param uri: URI of track + """ + if not re.match(TRACK_URI_RE, uri): + print(f'Error: {uri} is not a valid track URI') + exit(1) + audio_features = api.get_audio_features(uri.split(':')[2], headers) + track_info = api.request_data(uri, 'tracks', headers) + print('\t' + '\033[1m' + f'{track_info["name"]} - {", ".join(x["name"] for x in track_info["artists"])}' + '\033[0m') + print(f'Track URI{" " * 21}{track_info["uri"]}') + print(f'Artist URI(s){" " * 17}{", ".join(x["name"] + ": " + x["uri"] for x in track_info["artists"])}') + print(f'Album URI{" " * 21}{track_info["album"]["uri"]}') + print(f'Release date{" " * 18}{track_info["album"]["release_date"]}') + print(f'Duration{" " * 22}{audio_features["duration_ms"]}ms ({millis_to_stamp(audio_features["duration_ms"])})') + print(f'Key{" " * 27}{audio_features["key"]}') + print(f'Mode{" " * 26}{audio_features["mode"]} ({"minor" if audio_features["mode"] == 0 else "major"})') + print(f'Time signature{" " * 16}{audio_features["time_signature"]}') + print(f'Popularity{" " * 20}{track_info["popularity"]}') + print(f'Acousticness{" " * 18}{audio_features["acousticness"]}') + print(f'Danceability{" " * 18}{audio_features["danceability"]}') + print(f'Energy{" " * 24}{audio_features["energy"]}') + print(f'Instrumentalness{" " * 14}{audio_features["instrumentalness"]}') + print(f'Liveness{" " * 22}{audio_features["liveness"]}') + print(f'Loudness{" " * 22}{audio_features["loudness"]} dB') + print(f'Speechiness{" " * 19}{audio_features["speechiness"]}') + print(f'Valence{" " * 23}{audio_features["valence"]}') + print(f'Tempo{" " * 25}{audio_features["tempo"]} bpm') + + +def millis_to_stamp(x: int): + """ + Convert milliseconds to a timestamp on the form "{hours}h {minutes}m {seconds}s". Hours and minutes are only + included if they are present. + :param x: milliseconds + :return: formatted timestamp + """ + sec_total = int(x / 1000) + sec = sec_total % 60 + mins_total = math.floor(sec_total / 60) + mins = mins_total % 60 + hours = int(mins_total / 60) + return f'{f"{hours}h " if hours != 0 else ""}{f"{mins}m " if mins != 0 else ""}{sec}s' + + +def transfer_playback(device_id): try: - with open(devices_path, 'r') as file: - devices = json.loads(file.read()) - except json.decoder.JSONDecodeError: - print('You have no saved devices') + device = conf.get_devices()[device_id]['id'] + except KeyError: + print(f'Error: device {device_id} does not exist in config') exit(1) - print('Saved devices:') - print(f'ID{" " * 18}Name{" " * 16}Type') - print("-" * 50) - for x in devices: - print(f'{x}{" " * (20 - len(x))}{devices[x]["name"]}' - f'{" " * (20 - len(devices[x]["name"]))}{devices[x]["type"]}') + print(f'Transfering playback to device {device_id}') + api.transfer_playback(device, headers) def filter_recommendations(data: json) -> list: @@ -563,22 +696,36 @@ def filter_recommendations(data: json) -> list: :param data: recommendations as json object. :return: list of eligible track URIs """ - tracks = [] - with open(blacklist_path, 'r+') as file: - try: - blacklist = json.loads(file.read()) - blacklist_artists = [x for x in blacklist['artists'].keys()] - blacklist_tracks = [x for x in blacklist['tracks'].keys()] - for x in data['tracks']: - if x['uri'] in blacklist_artists: - continue - elif x['uri'] in blacklist_tracks: - continue - else: - tracks.append(x['uri']) - except json.decoder.JSONDecodeError: - tracks = [x['uri'] for x in data['tracks']] - return tracks + valid_tracks = [] + blacklist = conf.get_blacklist() + for x in data['tracks']: + # If the URI of the current track is blacklisted or there is an intersection between the set of blacklisted + # artists and the set of artists of the current track, then skip - otherwise add to valid tracks + if any(x['uri'] == s for s in blacklist['tracks'].keys()) or len( + set(blacklist['artists'].keys()) & set(y['uri'] for y in x['artists'])) > 0: + continue + else: + valid_tracks.append(x['uri']) + return valid_tracks + + +def print_tuning_options(): + try: + with open(f'{Path.home()}/.config/spotirec/tuning-opts', 'r') as file: + tuning_opts = file.readlines() + except FileNotFoundError: + print('Error: could not find tuning options file.') + exit(1) + if len(tuning_opts) == 0: + print('Error: tuning options file is empty.') + exit(1) + for x in tuning_opts: + if tuning_opts.index(x) == 0: + print('\033[1m' + x.strip('\n') + '\033[0m') + else: + print(x.strip('\n')) + print('Note that recommendations may be scarce outside the recommended ranges. If the recommended range is not ' + 'available, they may only be scarce at extreme values.') def recommend(): @@ -588,23 +735,48 @@ def recommend(): is printed to terminal. """ print('Getting recommendations') + # Create seed from user preferences rec.create_seed() - if args.ps: - save_preset(args.ps[0]) - tracks = filter_recommendations(api.get_recommendations(rec.rec_params, headers=headers)) + # Save as preset if requested + if args.save_preset: + save_preset(args.save_preset[0]) + # Filter blacklisted artists and tracks from recommendations + tracks = filter_recommendations(api.get_recommendations(rec.rec_params, headers)) + # If no tracks are left, notify an error and exit if len(tracks) == 0: print('Error: received zero tracks with your options - adjust and try again') exit(1) + if len(tracks) <= rec.limit_original / 2: + print(f'Warning: only received {len(tracks)} different recommendations, you may receive duplicates of ' + f'these (this might take a few seconds)') + # Filter recommendations until length of track list matches limit preference while True: if len(tracks) < rec.limit_original: rec.update_limit(rec.limit_original - len(tracks)) - tracks += filter_recommendations(api.get_recommendations(rec.rec_params, headers=headers)) + tracks += filter_recommendations(api.get_recommendations(rec.rec_params, headers)) else: break - rec.playlist_id = api.create_playlist(rec.playlist_name, rec.playlist_description(), headers=headers) - api.add_to_playlist(tracks, rec.playlist_id, headers=headers) + + def create_new_playlist(): + rec.playlist_id = api.create_playlist(rec.playlist_name, rec.playlist_description(), headers, cache_id=True) + api.add_to_playlist(tracks, rec.playlist_id, headers=headers) + + # Create playlist and add tracks + if args.preserve: + create_new_playlist() + else: + try: + rec.playlist_id = conf.get_playlists()['spotirec-default']['uri'].split(':')[2] + assert api.check_if_playlist_exists(rec.playlist_id, headers) is True + api.replace_playlist_tracks(rec.playlist_id, tracks, headers=headers) + api.update_playlist_details(rec.playlist_name, rec.playlist_description(), rec.playlist_id, headers=headers) + except (KeyError, AssertionError): + create_new_playlist() + # Generate and upload dank-ass image add_image_to_playlist(tracks) + # Print seed selection rec.print_selection() + # Start playing on input device if auto-play is present if rec.auto_play: api.play(rec.playback_device['id'], f'spotify:playlist:{rec.playlist_id}', headers) @@ -626,27 +798,38 @@ def parse(): add_to_blacklist(api.get_current_artists(headers)) exit(1) + if args.transfer_playback: + transfer_playback(args.transfer_playback[0]) + exit(1) + if args.s: print('Liking current track') - api.like_track(headers=headers) + api.like_track(headers) exit(1) elif args.sr: print('Unliking current track') - api.unlike_track(headers=headers) + api.unlike_track(headers) exit(1) - - if args.play: - rec.auto_play = True - print_devices() - elif args.play_device: - rec.auto_play = True - get_device(args.play_device[0]) - - if args.d: - print_devices(save_prompt=False) + if args.save_playlist: + save_playlist() + exit(1) + if args.remove_playlists: + remove_playlists(args.remove_playlists) + exit(1) + if args.save_device: + save_device() exit(1) - if args.dr: - remove_devices(args.dr) + if args.remove_devices: + remove_devices(args.remove_devices) + exit(1) + if args.remove_presets: + remove_presets(args.remove_presets) + exit(1) + if args.add_to: + add_current_track(args.add_to[0]) + exit(1) + elif args.remove_from: + remove_current_track(args.remove_from[0]) exit(1) if args.print: @@ -666,29 +849,43 @@ def parse(): print_blacklist() elif args.print[0] == 'devices': print_saved_devices() + elif args.print[0] == 'presets': + print_presets() + elif args.print[0] == 'playlists': + print_playlists() + elif args.print[0] == 'tuning': + print_tuning_options() + exit(1) + if args.track_features: + print_track_features(api.get_current_track(headers) if + args.track_features[0] == 'current' else args.track_features[0]) exit(1) + if args.play: + rec.auto_play = True + rec.playback_device = get_device(args.play[0]) + if args.a: print(f'Basing recommendations off your top {args.a} artist(s)') rec.based_on = 'top artists' rec.seed_type = 'artists' - parse_seed_info([x for x in api.get_top_list('artists', args.a, headers=headers)['items']]) + parse_seed_info([x for x in api.get_top_list('artists', args.a, headers)['items']]) elif args.t: print(f'Basing recommendations off your top {args.t} track(s)') rec.based_on = 'top tracks' rec.seed_type = 'tracks' - parse_seed_info([x for x in api.get_top_list('tracks', args.t, headers=headers)['items']]) + parse_seed_info([x for x in api.get_top_list('tracks', args.t, headers)['items']]) elif args.gcs: rec.based_on = 'custom seed genres' - print_choices(data=api.get_genre_seeds(headers=headers)['genres']) + print_choices(data=api.get_genre_seeds(headers)['genres']) elif args.ac: rec.based_on = 'custom artists' rec.seed_type = 'artists' - print_artists_or_tracks(api.get_top_list('artists', 50, headers=headers)) + print_artists_or_tracks(api.get_top_list('artists', 50, headers)) elif args.tc: rec.based_on = 'custom tracks' rec.seed_type = 'tracks' - print_artists_or_tracks(api.get_top_list('tracks', 50, headers=headers)) + print_artists_or_tracks(api.get_top_list('tracks', 50, headers)) elif args.gc: rec.based_on = 'custom top genres' print_choices(data=get_user_top_genres(), sort=True) @@ -696,12 +893,16 @@ def parse(): rec.based_on = 'custom mix' rec.seed_type = 'custom' print_choices(data=get_user_top_genres(), prompt=False, sort=True) - user_input = input('Enter a combination of 1-5 whitespace separated genre names, track uris, and artist uris. ' - '\nGenres with several words should be connected with dashes, e.g.; vapor-death-pop.\n') + try: + user_input = input( + 'Enter a combination of 1-5 whitespace separated genre names, track uris, and artist uris. ' + '\nGenres with several words should be connected with dashes, e.g.; vapor-death-pop.\n') + except KeyboardInterrupt: + exit(0) if not user_input: print('Please enter 1-5 seeds') exit(1) - parse_seed_info(user_input) + parse_seed_info(user_input.strip(' ')) else: print(f'Basing recommendations off your top {args.n} genres') add_top_genres_seed(args.n) @@ -712,7 +913,7 @@ def parse(): if args.tune: for x in args.tune: - check_tune_validity(args.tune[0]) + check_tune_validity(x) rec.rec_params[x.split('=')[0]] = x.split('=')[1] @@ -720,8 +921,8 @@ def parse(): headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {get_token()}'} -if args.p: - rec = load_preset(args.p[0]) +if args.load_preset: + rec = load_preset(args.load_preset[0]) else: rec = recommendation.Recommendation() parse() diff --git a/tuning-opts b/tuning-opts new file mode 100644 index 0000000..5b02678 --- /dev/null +++ b/tuning-opts @@ -0,0 +1,15 @@ +Attribute Data type Range Recommended range Function +duration_ms int R+ N/A The duration of the track in milliseconds +key int 0-11 N/A Pitch class of the track +mode int 0-1 N/A Modality of the track. 1 is major, 0 is minor +time_signature int N/A N/A Estimated overall time signature of the track +popularity int 0-100 0-100 Popularity of the track. High is popular, low is barely known +acousticness float 0-1 0-1 Confidence measure for whether or not the track is acoustic. High value is acoustic +danceability float 0-1 0.1-0.9 How well fit a track is for dancing. Measurement includes among others tempo, rhythm stability, and beat strength. High value is suitable for dancing +energy float 0-1 0-1 Perceptual measure of intensity and activity. High energy is fast, loud, and noisy, and low is slow and mellow +instrumentalness float 0-1 0-1 Whether or not a track contains vocals. Low contains vocals, high is purely instrumental +liveness float 0-1 0-0.4 Predicts whether or not a track is live. High value is live +loudness float -60-0 -20-0 Overall loudness of the track, measured in decibels +speechiness float 0-1 0-0.3 Presence of spoken words. Low is a song, and high is likely to be a talk show or podcast +valence float 0-1 0-1 Positivity of the track. High value is positive, low value is negative +tempo float 0-220 60-210 Overall estimated beats per minute of the track \ No newline at end of file