diff --git a/README.md b/README.md index 9e84e86..b732165 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) @@ -20,6 +20,8 @@ Script that creates a playlist of recommendations based on the user's top artist - [Blacklists](#blacklists) - [Autoplay](#autoplay) - [Devices](#devices) + - [Saving Playlists](#saving-playlists) + - [Saving Tracks](#saving-tracks) - [Printing](#printing) - [Troubleshooting](#troubleshooting) @@ -50,7 +52,7 @@ 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 spotirec.py oauth2.py conf.py recommendation.py api.py -t /usr/lib/spotirec ln -s /usr/lib/spotirec/spotirec.py /usr/bin/spotirec ``` @@ -108,17 +110,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 -ps name -l 50 --tune prefix_attribute=value prefix_attribute=value +$ spotirec -t --save-preset preset_name -l 50 --tune prefix_attribute=value prefix_attribute=value ``` -To load and use a saved preset, pass the `-p` argument followed by the name of the preset +To load and use a saved preset, pass the `--load-preset` flag followed by the name of the preset ``` -$ spotirec -p name +$ spotirec --load-preset preset_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 +145,104 @@ $ 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 | +| 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. | | 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. | | 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 | Any | 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: -Name Type ----------------------------------------- + +### 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 -d +$ 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 a saved device, pass the `-dr` option followed by an identifier for a device +To remove one or more saved playlists, pass the `--remove-playlists` flag followed by a sequence of names for playlists ``` -$ spotirec -dr laptop +$ 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 -s +``` +To remove the currently playing track from liked tracks, pass the `-sr` argument +``` +$ 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 ``` ## Troubleshooting diff --git a/api.py b/api.py index 9e89202..cdcb437 100644 --- a/api.py +++ b/api.py @@ -187,3 +187,26 @@ 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 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) \ No newline at end of file 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..d72388b 100644 --- a/spotirec.py +++ b/spotirec.py @@ -11,20 +11,19 @@ import oauth2 import recommendation import api +import conf 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', +PORT = 8080 +CONFIG_PATH = f'{Path.home()}/.config/spotirec' +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]' +URI_RE = r'spotify:(artist|track):[a-zA-Z0-9]' +PLAYLIST_URI_RE = r'spotify:playlist:[a-zA-Z0-9]' # OAuth handler sp_oauth = oauth2.SpotifyOAuth() @@ -37,8 +36,10 @@ 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') @@ -50,52 +51,52 @@ 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') +# 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('--load-preset', metavar='ID', nargs=1, type=str, help='load and use preset') +save_group.add_argument('--save-preset', metavar='ID', nargs=1, type=str, help='save options as preset') +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') +# 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') +# 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'], + choices=['artists', 'tracks', 'genres', 'genre-seeds', 'devices', 'blacklist', 'presets', + 'playlists'], 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() + 'TYPE=[artists|tracks|genres|genre-seeds|devices|blacklist|presets|playlists]') + +# Ensure config dir exists +if not os.path.isdir(CONFIG_PATH): + os.makedirs(CONFIG_PATH) def authorize(): @@ -104,7 +105,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 +133,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 +144,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 +181,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]}' @@ -193,6 +197,7 @@ def print_choices(data=None, prompt=True, sort=False) -> str: 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' + # 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(' ')]) else: @@ -220,9 +225,7 @@ 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,14 +235,17 @@ 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: + # Check prefix validity + if not tune.split('_', 1)[0] 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 not tune.split('=')[0].split('_', 1)[1] in TUNE_ATTR: print(f'Tune attribute \"{tune.split("=")[0].split("_", 1)[1]}\" is malformed - available attributes:') - print(tune_attr) + print(TUNE_ATTR) exit(1) + # Try parsing value to number try: float(tune.split('=')[1]) if '.' in tune.split('=')[1] else int(tune.split('=')[1]) except ValueError: @@ -255,14 +261,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 +281,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 +291,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 +315,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 +345,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 +353,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 +371,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 remove_presets(presets: list): + """ + Remove preset(s) from user config + :param presets: list of devices + """ + for x in presets: + conf.remove_preset(x) -def get_device(device_name: str): +def print_presets(): + """ + Format and print preset entries + """ + 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: - 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() - - -def print_devices(save_prompt=True, selection_prompt=True): - """ - 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 - """ - 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() + 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 +430,148 @@ def save_device(): Prompt user for an identifier for device and save to config """ + def prompt_device_index() -> int: + ind = input('Select a device by index[0]: ') or 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: + inp = input('Enter an identifier for your device: ') try: - inp = input('Enter an identifier for your device: ') 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"]}') + # 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 """ - try: - with open(devices_path, 'r') as file: - devices = json.loads(file.read()) - except json.decoder.JSONDecodeError: - print('You have no saved devices') - 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"]}') + 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: + iden = input('Please input an identifier for your playlist: ') + 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: + uri = input('Please input the URI for your playlist: ') + 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 filter_recommendations(data: json) -> list: @@ -563,22 +580,16 @@ 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 recommend(): @@ -588,23 +599,32 @@ 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) + # 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) + # Create playlist and add tracks + rec.playlist_id = api.create_playlist(rec.playlist_name, rec.playlist_description(), headers) + api.add_to_playlist(tracks, rec.playlist_id, headers) + # 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) @@ -628,25 +648,32 @@ def parse(): 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 +693,37 @@ 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() 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) @@ -720,8 +755,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()