From f87e83f7e7ca3f54277072be00b38dff7f7b92f1 Mon Sep 17 00:00:00 2001 From: Romain Gay Date: Mon, 17 Oct 2016 16:04:55 +0200 Subject: [PATCH] update code to 2.10.6 I don't have git history for this, sorry :( --- README.rst | 47 ++++++++--- spotify_ripper/main.py | 139 +++++++++++++++++++++++---------- spotify_ripper/post_actions.py | 70 +++++++++++++---- spotify_ripper/progress.py | 6 +- spotify_ripper/ripper.py | 83 +++++++++++++------- spotify_ripper/sync.py | 19 ++--- spotify_ripper/tags.py | 121 +++++++++++++++++++--------- spotify_ripper/utils.py | 55 ++++++++----- spotify_ripper/web.py | 75 ++++++++++++++---- 9 files changed, 434 insertions(+), 181 deletions(-) diff --git a/README.rst b/README.rst index 7e06d25..eb51775 100644 --- a/README.rst +++ b/README.rst @@ -56,6 +56,8 @@ Features - option to rip to FLAC, a loseless codec, instead of MP3 (requires extra ``flac`` dependency) +- option to rip to AIFF, a loseless codec, instead of MP3 (requires extra ``sox`` dependency) + - option to rip to Ogg Vorbis instead of MP3 (requires extra ``vorbis-tools`` dependency) - option to rip to Opus instead of MP3 (requires extra ``opus-tools`` dependency) @@ -81,8 +83,8 @@ Command Line .. code:: - usage: spotify-ripper [-h] [-S SETTINGS] [-a] [--aac] [--alac] - [--artist-album-type ARTIST_ALBUM_TYPE] + usage: spotify-ripper [-h] [-S SETTINGS] [-a] [--aac] [--aiff] [--alac] + [--all-artists] [--artist-album-type ARTIST_ALBUM_TYPE] [--artist-album-market ARTIST_ALBUM_MARKET] [-A] [-b BITRATE] [-c] [--comp COMP] [--comment COMMENT] [--cover-file COVER_FILE] @@ -91,15 +93,17 @@ Command Line [--format-case {upper,lower,capitalize}] [--flat] [--flat-with-index] [-g {artist,album}] [--grouping GROUPING] [--id3-v23] [-k KEY] [-u USER] - [-p PASSWORD] [-l] [-L LOG] [--pcm] [--mp4] - [--normalize] [-na] [-o] [--opus] + [-p PASSWORD] [--large-cover-art] [-l] [-L LOG] [--pcm] + [--mp4] [--normalize] [-na] [-o] [--opus] [--partial-check {none,weak,strict}] [--play-token-resume RESUME_AFTER] [--playlist-m3u] - [--playlist-wpl] [--playlist-sync] [-q VBR] - [-Q {160,320,96}] [--remove-offline-cache] - [--resume-after RESUME_AFTER] [-R REPLACE [REPLACE ...]] - [-s] [--stereo-mode {j,s,f,d,m,l,r}] - [--stop-after STOP_AFTER] [-V] [--wav] [--vorbis] [-r] + [--playlist-wpl] [--playlist-sync] [--plus-pcm] + [--plus-wav] [-q VBR] [-Q {160,320,96}] + [--remove-offline-cache] [--resume-after RESUME_AFTER] + [-R REPLACE [REPLACE ...]] [-s] + [--stereo-mode {j,s,f,d,m,l,r}] + [--stop-after STOP_AFTER] [--timeout TIMEOUT] [-V] + [--wav] [--windows-safe] [--vorbis] [-r] uri [uri ...] Rips Spotify URIs to MP3s with ID3 tags and album covers @@ -113,7 +117,9 @@ Command Line Path to settings, config and temp files directory [Default=~/.spotify-ripper] -a, --ascii Convert the file name and the metadata tags to ASCII encoding [Default=utf-8] --aac Rip songs to AAC format with FreeAAC instead of MP3 + --aiff Rip songs to lossless AIFF encoding instead of MP3 --alac Rip songs to Apple Lossless format instead of MP3 + --all-artists Store all artists, rather than just the main artist, in the track's metadata tag --artist-album-type ARTIST_ALBUM_TYPE Only load albums of specified types when passing a Spotify artist URI [Default=album,single,ep,compilation,appears_on] --artist-album-market ARTIST_ALBUM_MARKET @@ -147,6 +153,7 @@ Command Line -u USER, --user USER Spotify username -p PASSWORD, --password PASSWORD Spotify password [Default=ask interactively] + --large-cover-art Attempt to retrieve 640x640 cover art from Spotify's Web API [Default=300x300] -l, --last Use last login credentials -L LOG, --log LOG Log in a log-friendly format to a file (use - to log to stdout) --pcm Saves a .pcm file with the raw PCM data instead of MP3 @@ -156,13 +163,15 @@ Command Line Convert the file name to normalized ASCII with unicodedata.normalize (NFKD) -o, --overwrite Overwrite existing MP3 files [Default=skip] --opus Rip songs to Opus encoding instead of MP3 - --partial-check {none,weak,strict} - Check for and overwrite partially ripped files. "weak" will err on the side of not re-ripping the file if it is unsure, whereas "strict" will re-rip the file [Default=weak] + --partial-check {none,weak,weak:,strict} + Check for and overwrite partially ripped files. "weak" will err on the side of not re-ripping the file if it is unsure, whereas "strict" will re-rip the file. You can override the number of seconds of wiggle-room for the "weak" check using "weak:" [Default=weak:3] --play-token-resume RESUME_AFTER If the 'play token' is lost to a different device using the same Spotify account, the script will wait a speficied amount of time before restarting. This argument takes the same values as --resume-after [Default=abort] --playlist-m3u create a m3u file when ripping a playlist --playlist-wpl create a wpl file when ripping a playlist --playlist-sync Sync playlist songs (rename and remove old songs) + --plus-pcm Saves a .pcm file in addition to the encoded file (e.g. mp3) + --plus-wav Saves a .wav file in addition to the encoded file (e.g. mp3) -q VBR, --vbr VBR VBR quality setting or target bitrate for Opus [Default=0] -Q {160,320,96}, --quality {160,320,96} Spotify stream bitrate preference [Default=320] @@ -177,11 +186,13 @@ Command Line Advanced stereo settings for Lame MP3 encoder only --stop-after STOP_AFTER Stops script after a certain amount of time has passed (e.g. 1h30m). Alternatively, accepts a specific time in 24hr format to stop after (e.g 03:30, 16:15) + --timeout TIMEOUT Override the PySpotify timeout value in seconds (Default=10 seconds) -V, --version show program's version number and exit --wav Rip songs to uncompressed WAV file instead of MP3 + --windows-safe Make filename safe for Windows file system (truncate filename to 255 characters) --vorbis Rip songs to Ogg Vorbis encoding instead of MP3 -r, --remove-from-playlist - Delete tracks from playlist after successful ripping [Default=no] + [WARNING: SPOTIFY IS NOT PROPROGATING PLAYLIST CHANGES TO THEIR SERVERS] Delete tracks from playlist after successful ripping [Default=no] Example usage: rip a single file: spotify-ripper -u user spotify:track:52xaypL0Kjzk0ngwv3oBPR @@ -290,6 +301,8 @@ Format String Variables | | removed at the start of the string if it | | | exists | +-----------------------------------------+-----------------------------------------------+ +|``{track_uri}``, ``{uri}`` | Spotify track uri | ++-----------------------------------------+-----------------------------------------------+ Any substring in the format string that does not match a variable above will be passed through to the file/path name unchanged. @@ -342,6 +355,8 @@ Prerequisites - (optional) `fdkaac `__ +- (optional) `sox `__ + Mac OS X ~~~~~~~~ @@ -451,6 +466,9 @@ In addition to MP3 encoding, ``spotify-ripper`` supports encoding to FLAC, AAC, # Opus $ brew install opus-tools + # SoX + $ brew install sox + **Ubuntu/Debian** .. code:: bash @@ -479,6 +497,9 @@ In addition to MP3 encoding, ``spotify-ripper`` supports encoding to FLAC, AAC, # Opus $ sudo apt-get install opus-tools + # SoX + $ sudo apt-get install sox + Upgrade ~~~~~~~ @@ -509,3 +530,5 @@ License .. |Version| image:: http://img.shields.io/pypi/v/spotify-ripper.svg?style=flat-square :target: https://pypi.python.org/pypi/spotify-ripper + + diff --git a/spotify_ripper/main.py b/spotify_ripper/main.py index 8715dd7..9da831c 100644 --- a/spotify_ripper/main.py +++ b/spotify_ripper/main.py @@ -34,11 +34,7 @@ def load_config(defaults): return defaults config_items = dict(config.items("main")) - to_array_options = [ - "comment", "cover_file", "cover_file_and_embed", "directory", - "fail_log", "format", "genres", "grouping", "key", "user", - "password", "log", "artist_album_type", "replace", "partial_check", - "artist_album_market"] + to_array_options = ["replace"] # coerce boolean and none types config_items_new = {} @@ -55,7 +51,7 @@ def load_config(defaults): else: item = item.strip("'\"") - # certain options need to be in array (nargs=1) + # certain options need to be in array (nargs=+) if u_key in to_array_options: item = [item] @@ -99,6 +95,19 @@ def __fixed_render_cover(self, key, value): MP4Tags._MP4Tags__atoms[b"covr"] = ( MP4Tags._MP4Tags__parse_cover, MP4Tags.__fixed_render_cover) +def partial_check_type(v): + valid_choices = {'none', 'weak', 'strict'} + + if v in valid_choices: + return v + + # allow user to override the "wiggle-room" for a weak check + import re + try: + return re.match("^weak:[0-9]+$", v).group(0) + except: + raise argparse.ArgumentTypeError("String '" + v + + "' does not match none, weak, weak:, strict") def main(prog_args=sys.argv[1:]): # in case we changed the location of the settings directory where the @@ -107,7 +116,7 @@ def main(prog_args=sys.argv[1:]): # config file) settings_parser = argparse.ArgumentParser(add_help=False) settings_parser.add_argument( - '-S', '--settings', nargs=1, + '-S', '--settings', help='Path to settings, config and temp files directory ' '[Default=~/.spotify-ripper]') args, remaining_argv = settings_parser.parse_known_args(prog_args) @@ -162,15 +171,22 @@ def main(prog_args=sys.argv[1:]): encoding_group.add_argument( '--aac', action='store_true', help='Rip songs to AAC format with FreeAAC instead of MP3') + encoding_group.add_argument( + '--aiff', action='store_true', + help='Rip songs to lossless AIFF encoding instead of MP3') encoding_group.add_argument( '--alac', action='store_true', help='Rip songs to Apple Lossless format instead of MP3') parser.add_argument( - '--artist-album-type', nargs=1, + '--all-artists', action='store_true', + help='Store all artists, rather than just the main artist, in the ' + 'track\'s metadata tag') + parser.add_argument( + '--artist-album-type', help='Only load albums of specified types when passing a Spotify ' 'artist URI [Default=album,single,ep,compilation,appears_on]') parser.add_argument( - '--artist-album-market', nargs=1, + '--artist-album-market', help='Only load albums with the specified ISO2 country code when ' 'passing a Spotify artist URI. You may get duplicate albums ' 'if not set. [Default=any]') @@ -186,27 +202,27 @@ def main(prog_args=sys.argv[1:]): '--comp', help='compression complexity for FLAC and Opus [Default=Max]') parser.add_argument( - '--comment', nargs=1, + '--comment', help='Set comment metadata tag to all songs. Can include ' 'same tags as --format.') parser.add_argument( - '--cover-file', nargs=1, + '--cover-file', help='Save album cover image to file name (e.g "cover.jpg") ' '[Default=embed]') parser.add_argument( - '--cover-file-and-embed', nargs=1, metavar="COVER_FILE", + '--cover-file-and-embed', metavar="COVER_FILE", help='Same as --cover-file but embeds the cover image too') parser.add_argument( - '-d', '--directory', nargs=1, + '-d', '--directory', help='Base directory where ripped MP3s are saved [Default=cwd]') parser.add_argument( - '--fail-log', nargs=1, + '--fail-log', help="Logs the list of track URIs that failed to rip") encoding_group.add_argument( '--flac', action='store_true', help='Rip songs to lossless FLAC encoding instead of MP3') parser.add_argument( - '-f', '--format', nargs=1, + '-f', '--format', help='Save songs using this path and filename structure (see README)') parser.add_argument( '--format-case', choices=['upper', 'lower', 'capitalize'], @@ -221,32 +237,36 @@ def main(prog_args=sys.argv[1:]): help='Similar to --flat [-f] but includes the playlist index at ' 'the start of the song file') parser.add_argument( - '-g', '--genres', nargs=1, + '-g', '--genres', choices=['artist', 'album'], help='Attempt to retrieve genre information from Spotify\'s ' 'Web API [Default=skip]') parser.add_argument( - '--grouping', nargs=1, + '--grouping', help='Set grouping metadata tag to all songs. Can include ' 'same tags as --format.') encoding_group.add_argument( '--id3-v23', action='store_true', help='Store ID3 tags using version v2.3 [Default=v2.4]') parser.add_argument( - '-k', '--key', nargs=1, + '-k', '--key', help='Path to Spotify application key file ' '[Default=Settings Directory]') group.add_argument( - '-u', '--user', nargs=1, + '-u', '--user', help='Spotify username') parser.add_argument( - '-p', '--password', nargs=1, + '-p', '--password', help='Spotify password [Default=ask interactively]') + parser.add_argument( + '--large-cover-art', action='store_true', + help='Attempt to retrieve 640x640 cover art from Spotify\'s Web API ' + '[Default=300x300]') group.add_argument( '-l', '--last', action='store_true', help='Use last login credentials') parser.add_argument( - '-L', '--log', nargs=1, + '-L', '--log', help='Log in a log-friendly format to a file (use - to log to stdout)') encoding_group.add_argument( '--pcm', action='store_true', @@ -269,10 +289,12 @@ def main(prog_args=sys.argv[1:]): '--opus', action='store_true', help='Rip songs to Opus encoding instead of MP3') parser.add_argument( - '--partial-check', choices=['none', 'weak', 'strict'], + '--partial-check', metavar="{none,weak,weak:,strict}", type=partial_check_type, help='Check for and overwrite partially ripped files. "weak" will ' 'err on the side of not re-ripping the file if it is unsure, ' - 'whereas "strict" will re-rip the file [Default=weak]') + 'whereas "strict" will re-rip the file. You can override the ' + 'number of seconds of wiggle-room for the "weak" check using ' + '"weak:" [Default=weak:3]') parser.add_argument( '--play-token-resume', metavar="RESUME_AFTER", help='If the \'play token\' is lost to a different device using ' @@ -281,13 +303,25 @@ def main(prog_args=sys.argv[1:]): 'values as --resume-after [Default=abort]') parser.add_argument( '--playlist-m3u', action='store_true', - help='create a m3u file when ripping a playlist') + help='create a m3u file with relative paths when ripping a playlist') + parser.add_argument( + '--playlist-absolute-paths', action='store_true', default=False, + help='creates the playlist file with absolute paths instead of relative') + parser.add_argument( + '--playlist-directory', + help='creates the playlist file in another directory [Default=Song Directory]') parser.add_argument( '--playlist-wpl', action='store_true', help='create a wpl file when ripping a playlist') parser.add_argument( '--playlist-sync', action='store_true', help='Sync playlist songs (rename and remove old songs)') + parser.add_argument( + '--plus-pcm', action='store_true', + help='Saves a .pcm file in addition to the encoded file (e.g. mp3)') + parser.add_argument( + '--plus-wav', action='store_true', + help='Saves a .wav file in addition to the encoded file (e.g. mp3)') parser.add_argument( '-q', '--vbr', help='VBR quality setting or target bitrate for Opus [Default=0]') @@ -308,8 +342,7 @@ def main(prog_args=sys.argv[1:]): '-R', '--replace', nargs="+", required=False, help='pattern to replace the output filename separated by "/". ' 'The following example replaces all spaces with "_" and all "-" ' - 'with ".":' - ' spotify-ripper --replace " /_" "\-/." uri') + 'with ".": spotify-ripper --replace " /_" "\-/." uri') parser.add_argument( '-s', '--strip-colors', action='store_true', help='Strip coloring from output [Default=colors]') @@ -321,17 +354,25 @@ def main(prog_args=sys.argv[1:]): help='Stops script after a certain amount of time has passed ' '(e.g. 1h30m). Alternatively, accepts a specific time in 24hr ' 'format to stop after (e.g 03:30, 16:15)') + parser.add_argument( + '--timeout', type=int, + help='Override the PySpotify timeout value in seconds (Default=10 seconds)') parser.add_argument( '-V', '--version', action='version', version=prog_version) encoding_group.add_argument( '--wav', action='store_true', help='Rip songs to uncompressed WAV file instead of MP3') + parser.add_argument( + '--windows-safe', action='store_true', + help='Make filename safe for Windows file system ' + '(truncate filename to 255 characters)') encoding_group.add_argument( '--vorbis', action='store_true', help='Rip songs to Ogg Vorbis encoding instead of MP3') parser.add_argument( '-r', '--remove-from-playlist', action='store_true', - help='Delete tracks from playlist after successful ' + help='[WARNING: SPOTIFY IS NOT PROPROGATING PLAYLIST CHANGES TO ' + 'THEIR SERVERS] Delete tracks from playlist after successful ' 'ripping [Default=no]') parser.add_argument( 'uri', nargs="+", @@ -352,10 +393,11 @@ def wrap_stream(stream, convert, strip, autoreset, wrap): args.has_log = args.log is not None if args.has_log: - if args.log[0] == "-": + if args.log == "-": init(strip=True) else: - log_file = open(args.log[0], 'a') + encoding = "ascii" if args.ascii else "utf-8" + log_file = codecs.open(enc_str(args.log), 'a', encoding) sys.stdout = wrap_stream(log_file, None, True, False, True) else: init(strip=True if args.strip_colors else None) @@ -369,12 +411,20 @@ def wrap_stream(stream, convert, strip, autoreset, wrap): sys.stdout = codecs.getwriter('utf-8')(sys.stdout) # small sanity check on user option - if args.user is not None and args.user[0] == "USER": + if args.user is not None and args.user == "USER": print(Fore.RED + "Please pass your username as --user " + " instead of --user USER " + "..." + Fore.RESET) sys.exit(1) + # give warning for broken feature + if args.remove_from_playlist: + print(Fore.RED + "--REMOVE-FROM-PLAYLIST WARNING:") + print("SPOTIFY IS NOT PROPROGATING PLAYLIST CHANGES " + "TO THEIR SERVERS.") + print("YOU WILL NOT SEE ANY CHANGES TO YOUR PLAYLIST ON THE " + + "OFFICIAL SPOTIFY DESKTOP OR WEB APP." + Fore.RESET) + if args.wav: args.output_type = "wav" elif args.pcm: @@ -407,6 +457,7 @@ def wrap_stream(stream, convert, strip, autoreset, wrap): # check that encoder tool is available encoders = { "flac": ("flac", "flac"), + "aiff": ("sox", "sox"), "aac": ("faac", "faac"), "ogg": ("oggenc", "vorbis-tools"), "opus": ("opusenc", "opus-tools"), @@ -428,11 +479,11 @@ def wrap_stream(stream, convert, strip, autoreset, wrap): # format string if args.flat: - args.format = ["{artist} - {track_name}.{ext}"] + args.format = "{artist} - {track_name}.{ext}" elif args.flat_with_index: - args.format = ["{idx:3} - {artist} - {track_name}.{ext}"] + args.format = "{idx:3} - {artist} - {track_name}.{ext}" elif args.format is None: - args.format = ["{album_artist}/{album}/{artist} - {track_name}.{ext}"] + args.format = "{album_artist}/{album}/{artist} - {track_name}.{ext}" # print some settings print(Fore.GREEN + "Spotify Ripper - v" + prog_version + Fore.RESET) @@ -445,6 +496,8 @@ def encoding_output_str(): else: if args.output_type == "flac": return "FLAC, Compression Level: " + args.comp + elif args.output_type == "aiff": + return "AIFF" elif args.output_type == "alac.m4a": return "Apple Lossless (ALAC)" elif args.output_type == "ogg": @@ -499,12 +552,13 @@ def unicode_support_str(): print(Fore.YELLOW + " Settings directory:\t" + Fore.RESET + settings_dir()) - print(Fore.YELLOW + " Format String:\t" + Fore.RESET + args.format[0]) + print(Fore.YELLOW + " Format String:\t" + Fore.RESET + args.format) print(Fore.YELLOW + " Overwrite files:\t" + Fore.RESET + ("Yes" if args.overwrite else "No")) # patch a bug when Python 3/MP4 - if sys.version_info >= (3, 0) and args.output_type == "m4a": + if sys.version_info >= (3, 0) and \ + (args.output_type == "m4a" or args.output_type == "alac.m4a"): patch_bug_in_mutagen() ripper = Ripper(args) @@ -533,7 +587,9 @@ def skip(): # check if we were passed a file name or search def check_uri_args(): if len(args.uri) == 1 and path_exists(args.uri[0]): - args.uri = [line.strip() for line in open(args.uri[0]) + encoding = "ascii" if args.ascii else "utf-8" + args.uri = [line.strip() for line in + codecs.open(enc_str(args.uri[0]), 'r', encoding) if not line.strip().startswith("#") and len(line.strip()) > 0] elif len(args.uri) == 1 and not args.uri[0].startswith("spotify:"): args.uri = [list(ripper.search_query(args.uri[0]))] @@ -556,15 +612,17 @@ def check_uri_args(): abort(set_logged_in=True) # wait for ripping thread to finish - stdin_settings = termios.tcgetattr(sys.stdin) + if not args.has_log: + stdin_settings = termios.tcgetattr(sys.stdin) try: - tty.setcbreak(sys.stdin.fileno()) + if not args.has_log: + tty.setcbreak(sys.stdin.fileno()) while ripper.isAlive(): schedule.run_pending() # check if the escape button was pressed - if hasStdinData(): + if not args.has_log and hasStdinData(): c = sys.stdin.read(1) if c == '\x1b': skip() @@ -575,7 +633,8 @@ def check_uri_args(): print("\n" + Fore.RED + "Aborting..." + Fore.RESET) abort() finally: - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, stdin_settings) + if not args.has_log: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, stdin_settings) if __name__ == '__main__': main() diff --git a/spotify_ripper/post_actions.py b/spotify_ripper/post_actions.py index 8a4464b..b7e3214 100644 --- a/spotify_ripper/post_actions.py +++ b/spotify_ripper/post_actions.py @@ -28,8 +28,9 @@ def __init__(self, args, ripper): os.makedirs(enc_str(_base_dir)) encoding = "ascii" if args.ascii else "utf-8" - self.fail_log_file = codecs.open(os.path.join( - _base_dir, args.fail_log[0]), 'w', encoding) + self.fail_log_file = codecs.open( + enc_str(os.path.join(_base_dir, args.fail_log)), + 'w', encoding) def log_success(self, track): self.success_tracks.append(track) @@ -63,7 +64,7 @@ def print_with_bullet(_str): def log_tracks(tracks): for track in tracks: try: - track.load() + track.load(self.args.timeout) if (len(track.artists) > 0 and track.artists[0].name is not None and track.name is not None): print_with_bullet(track.artists[0].name + " - " + @@ -163,6 +164,33 @@ def get_playlist_name(self): else: return None + def get_playlist_path(self, name, ext): + ext = "." + ext + + if self.args.playlist_directory is not None: + playlist_dir = self.args.playlist_directory + + # check to see if we were passed in a playlist filename + if playlist_dir.endswith(ext): + playlist_file = playlist_dir + playlist_dir = os.path.dirname(playlist_dir) + else: + playlist_file = to_ascii(os.path.join(playlist_dir, name + ext)) + + # ensure path exists + if not os.path.exists(playlist_dir): + os.makedirs(playlist_dir) + return playlist_file + else: + return to_ascii(os.path.join(base_dir(), name + ext)) + + def get_playlist_file_path(self, _file): + _base_dir = base_dir() + if self.args.playlist_absolute_paths: + return os.path.join(_base_dir, _file) + else: + return os.path.relpath(_file, _base_dir) + def create_playlist_m3u(self, tracks): args = self.args ripper = self.ripper @@ -170,23 +198,21 @@ def create_playlist_m3u(self, tracks): name = self.get_playlist_name() if name is not None and args.playlist_m3u: name = sanitize_playlist_name(to_ascii(name)) - _base_dir = base_dir() - playlist_path = to_ascii( - os.path.join(_base_dir, name + '.m3u')) + playlist_path = self.get_playlist_path(name, "m3u") print(Fore.GREEN + "Creating playlist m3u file " + playlist_path + Fore.RESET) encoding = "ascii" if args.ascii else "utf-8" - with codecs.open(playlist_path, 'w', encoding) as playlist: + with codecs.open(enc_str(playlist_path), 'w', encoding) as playlist: for idx, track in enumerate(tracks): - track.load() + track.load(args.timeout) if track.is_local: continue _file = ripper.format_track_path(idx, track) if path_exists(_file): - playlist.write(os.path.relpath(_file, _base_dir) + - "\n") + playlist.write(self.get_playlist_file_path(_file) + + "\n") def create_playlist_wpl(self, tracks): args = self.args @@ -195,19 +221,17 @@ def create_playlist_wpl(self, tracks): name = self.get_playlist_name() if name is not None and args.playlist_wpl: name = sanitize_playlist_name(to_ascii(name)) - _base_dir = base_dir() - playlist_path = to_ascii( - os.path.join(_base_dir, name + '.wpl')) + playlist_path = self.get_playlist_path(name, "wpl") print(Fore.GREEN + "Creating playlist wpl file " + playlist_path + Fore.RESET) encoding = "ascii" if args.ascii else "utf-8" - with codecs.open(playlist_path, 'w', encoding) as playlist: + with codecs.open(enc_str(playlist_path), 'w', encoding) as playlist: # to get an accurate track count track_paths = [] for idx, track in enumerate(tracks): - track.load() + track.load(args.timeout) if track.is_local: continue _file = ripper.format_track_path(idx, track) @@ -233,7 +257,7 @@ def create_playlist_wpl(self, tracks): _file.replace("&", "&") _file.replace("'", "'") playlist.write('\t\t\t\n") playlist.write('\t\t\n') playlist.write('\t\n') @@ -246,6 +270,18 @@ def clean_up_partial(self): print(Fore.YELLOW + "Deleting partially ripped file" + Fore.RESET) rm_file(ripper.audio_file) + # check for any extra pcm or wav files + def delete_extra_file(ext): + audio_file = change_file_extension(ripper.audio_file, ext) + if path_exists(audio_file): + rm_file(audio_file) + + if self.args.plus_wav: + delete_extra_file("wav") + + if self.args.plus_pcm: + delete_extra_file("pcm") + def queue_remove_from_playlist(self, idx): ripper = self.ripper @@ -284,7 +320,7 @@ def remove_offline_cache(self): if self.args.remove_offline_cache: if self.args.settings is not None: - storage_path = norm_path(self.args.settings[0]) + storage_path = norm_path(self.args.settings) else: storage_path = default_settings_dir() diff --git a/spotify_ripper/progress.py b/spotify_ripper/progress.py index 6231c64..c07bbed 100644 --- a/spotify_ripper/progress.py +++ b/spotify_ripper/progress.py @@ -65,7 +65,7 @@ def calc_total(self, track_pairs): track = pair[0] audio_file = pair[1] - track.load() + track.load(self.args.timeout) # check if we should skip track if track.availability != 1 or track.is_local: self.skipped_tracks += 1 @@ -138,12 +138,14 @@ def handle_resize(self, signum=None, frame=None): except (NameError, IOError): self.term_width = int(os.environ.get('COLUMNS', 120)) - 1 + def increment_track_idx(self): + self.track_idx += 1 + def prepare_track(self, track): self.song_position = 0 self.song_duration = track.duration self.move_cursor = False self.current_track = track - self.track_idx += 1 def end_track(self, show_end=True): if show_end: diff --git a/spotify_ripper/ripper.py b/spotify_ripper/ripper.py index 620ebcc..7a1dd9a 100644 --- a/spotify_ripper/ripper.py +++ b/spotify_ripper/ripper.py @@ -22,6 +22,7 @@ import wave import re import select +import traceback try: # Python 3 @@ -94,7 +95,7 @@ def __init__(self, args): # application key location if args.key is not None: - config.load_application_key_file(args.key[0]) + config.load_application_key_file(args.key) else: if not path_exists(default_dir): os.makedirs(enc_str(default_dir)) @@ -111,7 +112,7 @@ def __init__(self, args): # settings directory if args.settings is not None: - settings_dir = norm_path(args.settings[0]) + settings_dir = norm_path(args.settings) config.settings_location = settings_dir config.cache_location = settings_dir else: @@ -169,9 +170,9 @@ def login(self): if args.password is None: password = getpass.getpass() - self.login_as_user(args.user[0], password) + self.login_as_user(args.user, password) else: - self.login_as_user(args.user[0], args.password[0]) + self.login_as_user(args.user, args.password) return self.login_success @@ -259,8 +260,11 @@ def get_tracks_from_uri(uri): if self.abort.is_set(): break + # before we skip or can fail loading the track + self.progress.increment_track_idx() + print('Loading track...') - track.load() + track.load(args.timeout) if track.availability != 1 or track.is_local: print( Fore.RED + 'Track is not available, ' @@ -336,10 +340,14 @@ def get_tracks_from_uri(uri): # tracks from the playlist when everything is done self.post.queue_remove_from_playlist(idx) + # finally log success + self.post.log_success(track) + except (spotify.Error, Exception) as e: if isinstance(e, Exception): print(Fore.RED + "Spotify error detected" + Fore.RESET) print(str(e)) + traceback.print_exc() print("Skipping to next track...") self.session.player.play(False) self.post.clean_up_partial() @@ -408,6 +416,7 @@ def load_link(self, uri): if not uri: return iter([]) + args = self.args link = self.session.get_link(uri) if link.type == spotify.LinkType.TRACK: track = link.as_track() @@ -428,7 +437,7 @@ def load_link(self, uri): attempt_count += 1 print('Loading playlist...') - self.current_playlist.load() + self.current_playlist.load(args.timeout) return iter(self.current_playlist.tracks) elif link.type == spotify.LinkType.STARRED: link_user = link.as_user() @@ -454,20 +463,20 @@ def load_starred(): attempt_count += 1 print('Loading starred playlist...') - starred.load() + starred.load(args.timeout) return iter(starred.tracks) elif link.type == spotify.LinkType.ALBUM: album = link.as_album() album_browser = album.browse() print('Loading album browser...') - album_browser.load() + album_browser.load(args.timeout) self.current_album = album return iter(album_browser.tracks) elif link.type == spotify.LinkType.ARTIST: artist = link.as_artist() artist_browser = artist.browse() print('Loading artist browser...') - artist_browser.load() + artist_browser.load(args.timeout) return iter(artist_browser.tracks) return iter([]) @@ -476,7 +485,7 @@ def search_query(self, query): try: result = self.session.search(query) - result.load() + result.load(self.args.timeout) except spotify.Error as e: print(str(e)) return iter([]) @@ -601,21 +610,21 @@ def format_track_path(self, idx, track): args = self.args # check if we cached the result already - track.load() + track.load(args.timeout) if track.link.uri in self.track_path_cache: return self.track_path_cache[track.link.uri] audio_file = \ - format_track_string(self, args.format[0].strip(), idx, track) + format_track_string(self, args.format.strip(), idx, track) # in case the file name is too long def truncate(_str, max_size): return _str[:max_size].strip() if len(_str) > max_size else _str def truncate_dir_path(dir_path): - path_tokens = dir_path.split(os.pathsep) + path_tokens = dir_path.split(os.sep) path_tokens = [truncate(token, 255) for token in path_tokens] - return os.pathsep.join(path_tokens) + return os.sep.join(path_tokens) def truncate_file_name(file_name): tokens = file_name.rsplit(os.extsep, 1) @@ -626,19 +635,21 @@ def truncate_file_name(file_name): return os.extsep.join(tokens) # ensure each component in path is no more than 255 chars long - tokens = audio_file.rsplit(os.pathsep, 1) - if len(tokens) > 1: - audio_file = os.path.join( - truncate_dir_path(tokens[0]), truncate_file_name(tokens[1])) - else: - audio_file = truncate_file_name(tokens[0]) + if args.windows_safe: + tokens = audio_file.rsplit(os.sep, 1) + if len(tokens) > 1: + audio_file = os.path.join( + truncate_dir_path(tokens[0]), truncate_file_name(tokens[1])) + else: + audio_file = truncate_file_name(tokens[0]) # replace filename if args.replace is not None: audio_file = self.replace_filename(audio_file, args.replace) - # remove not allowed characters in filename and encode utf-8 - audio_file = audio_file.replace('*."/\[]:;|=,', '') + # remove not allowed characters in filename (windows) + if args.windows_safe: + audio_file = re.sub('[:"*?<>|]', '', audio_file) # prepend base_dir audio_file = to_ascii(os.path.join(base_dir(), audio_file)) @@ -679,19 +690,34 @@ def prepare_rip(self, idx, track): file_size = calc_file_size(track) print("Track Download Size: " + format_size(file_size)) + if args.output_type == "wav" or args.plus_wav: + audio_file = change_file_extension(self.audio_file, "wav") if \ + args.output_type != "wav" else self.audio_file + wav_file = audio_file if sys.version_info >= (3, 0) \ + else enc_str(audio_file) + self.wav_file = wave.open(wav_file, "wb") + self.wav_file.setparams((2, 2, 44100, 0, 'NONE', 'not compressed')) + + if args.output_type == "pcm" or args.plus_pcm: + audio_file = change_file_extension(self.audio_file, "pcm") if \ + args.output_type != "pcm" else self.audio_file + self.pcm_file = open(enc_str(audio_file), 'wb') + audio_file_enc = enc_str(self.audio_file) - if args.output_type == "wav": - self.wav_file = wave.open(audio_file_enc, "wb") - self.wav_file.setparams((2, 2, 44100, 0, 'NONE', 'not compressed')) - elif args.output_type == "pcm": - self.pcm_file = open(audio_file_enc, 'wb') - elif args.output_type == "flac": + if args.output_type == "flac": self.rip_proc = Popen( ["flac", "-f", ("-" + str(args.comp)), "--silent", "--endian", "little", "--channels", "2", "--bps", "16", "--sample-rate", "44100", "--sign", "signed", "-o", audio_file_enc, "-"], stdin=PIPE) + elif args.output_type == "aiff": + self.rip_proc = Popen( + ["sox", "-q", "--endian", + "little", "--channels", "2", "--bits", "16", "--rate", + "44100", "--encoding", "unsigned-integer", "-t", "raw", + "-", audio_file_enc], + stdin=PIPE) elif args.output_type == "alac.m4a": self.rip_proc = Popen( ["avconv", "-nostats", "-loglevel", "0", "-f", "s16le", "-ar", @@ -786,7 +812,6 @@ def finish_rip(self, track): self.pcm_file = None self.ripping.clear() - self.post.log_success(track) def rip(self, session, sample_rate, frame_bytes, num_frames): if self.ripping.is_set(): diff --git a/spotify_ripper/sync.py b/spotify_ripper/sync.py index 04374cc..9d9ddef 100644 --- a/spotify_ripper/sync.py +++ b/spotify_ripper/sync.py @@ -9,7 +9,7 @@ import codecs import copy import spotify - +import re class Sync(object): @@ -27,21 +27,21 @@ def sync_lib_path(self, playlist): # lib path if args.settings is not None: - lib_path = os.path.join(norm_path(args.settings[0]), "Sync") + lib_path = os.path.join(norm_path(args.settings), "Sync") else: lib_path = os.path.join(default_settings_dir(), "Sync") if not path_exists(lib_path): os.makedirs(enc_str(lib_path)) - return os.path.join(enc_str(lib_path), enc_str(uri_tokens[4] + ".json")) + return os.path.join(lib_path, uri_tokens[4] + ".json") def save_sync_library(self, playlist, lib): args = self.args lib_path = self.sync_lib_path(playlist) encoding = "ascii" if args.ascii else "utf-8" - with codecs.open(lib_path, 'w', encoding) as lib_file: + with codecs.open(enc_str(lib_path), 'w', encoding) as lib_file: lib_file.write( json.dumps(lib, ensure_ascii=args.ascii, indent=4, separators=(',', ': '))) @@ -50,25 +50,26 @@ def load_sync_library(self, playlist): args = self.args lib_path = self.sync_lib_path(playlist) - if os.path.exists(lib_path): + if path_exists(lib_path): encoding = "ascii" if args.ascii else "utf-8" - with codecs.open(lib_path, 'r', encoding) as lib_file: + with codecs.open(enc_str(lib_path), 'r', encoding) as lib_file: return json.loads(lib_file.read()) else: return {} def sync_playlist(self, playlist): args = self.args - playlist.load() + playlist.load(args.timeout) lib = self.load_sync_library(playlist) new_lib = {} + nonalpha = re.compile('[^a-zA-Z0-9-_ ]') - print("Syncing playlist " + to_ascii(playlist.name)) + print("Syncing playlist " + nonalpha.sub('', playlist.name) + " (" + playlist.link.uri + ")") # create new lib for idx, track in enumerate(playlist.tracks): try: - track.load() + track.load(args.timeout) if track.availability != 1 or track.is_local: continue diff --git a/spotify_ripper/tags.py b/spotify_ripper/tags.py index 190df1c..4f7c3ff 100644 --- a/spotify_ripper/tags.py +++ b/spotify_ripper/tags.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from colorama import Fore, Style -from mutagen import mp3, id3, flac, oggvorbis, oggopus, aac +from mutagen import mp3, id3, flac, aiff, oggvorbis, oggopus, aac from stat import ST_SIZE from spotify_ripper.utils import * from datetime import datetime @@ -15,8 +15,9 @@ def set_metadata_tags(args, audio_file, idx, track, ripper): # log completed file print(Fore.GREEN + Style.BRIGHT + os.path.basename(audio_file) + - Style.NORMAL + "\t[ " + format_size(os.stat(audio_file)[ST_SIZE]) + - " ]" + Fore.RESET) + Style.NORMAL + "\t[ " + + format_size(os.stat(enc_str(audio_file))[ST_SIZE]) + " ]" + + Fore.RESET) if args.output_type == "wav" or args.output_type == "pcm": print(Fore.YELLOW + "Skipping metadata tagging for " + @@ -25,11 +26,11 @@ def set_metadata_tags(args, audio_file, idx, track, ripper): # ensure everything is loaded still if not track.is_loaded: - track.load() + track.load(args.timeout) if not track.album.is_loaded: - track.album.load() + track.album.load(args.timeout) album_browser = track.album.browse() - album_browser.load() + album_browser.load(args.timeout) # calculate num of tracks on disc and num of dics num_discs = 0 @@ -44,32 +45,44 @@ def set_metadata_tags(args, audio_file, idx, track, ripper): # try to get genres from Spotify's Web API genres = None if args.genres is not None: - genres = ripper.web.get_genres(args.genres[0], track) + genres = ripper.web.get_genres(args.genres, track) # use mutagen to update id3v2 tags and vorbis comments try: audio = None on_error = 'replace' if args.ascii_path_only else 'ignore' album = to_ascii(track.album.name, on_error) - artist = to_ascii(track.artists[0].name, on_error) + artists = ", ".join([artist.name for artist in track.artists]) \ + if args.all_artists else track.artists[0].name + artists_ascii = to_ascii(artists, on_error) + album_artist = to_ascii(track.album.artist.name, on_error) title = to_ascii(track.name, on_error) # the comment tag can be formatted if args.comment is not None: comment = \ - format_track_string(ripper, args.comment[0], idx, track) + format_track_string(ripper, args.comment, idx, track) comment_ascii = to_ascii(comment, on_error) if args.grouping is not None: grouping = \ - format_track_string(ripper, args.grouping[0], idx, track) + format_track_string(ripper, args.grouping, idx, track) grouping_ascii = to_ascii(grouping, on_error) if genres is not None and genres: genres_ascii = [to_ascii(genre) for genre in genres] # cover art image - image = track.album.cover() + image = None + if args.large_cover_art: + image = ripper.web.get_large_coverart(track.link.uri) + + # if we fail, use regular cover size + if image is None: + image = track.album.cover() + if image is not None: + image.load(args.timeout) + image = image.data def tag_to_ascii(_str, _str_ascii): return _str if args.ascii_path_only else _str_ascii @@ -82,34 +95,33 @@ def idx_of_total_str(_idx, _total): def save_cover_image(embed_image_func): if image is not None: - image.load() def write_image(file_name): cover_path = os.path.dirname(audio_file) cover_file = os.path.join(cover_path, file_name) if not path_exists(cover_file): - with open(cover_file, "wb") as f: - f.write(image.data) + with open(enc_str(cover_file), "wb") as f: + f.write(image) if args.cover_file is not None: - write_image(args.cover_file[0]) + write_image(args.cover_file) elif args.cover_file_and_embed is not None: - write_image(args.cover_file_and_embed[0]) - embed_image_func() + write_image(args.cover_file_and_embed) + embed_image_func(image) else: - embed_image_func() + embed_image_func(image) def set_id3_tags(audio): # add ID3 tag if it doesn't exist audio.add_tags() - def embed_image(): + def embed_image(data): audio.tags.add( id3.APIC( encoding=3, mime='image/jpeg', type=3, desc='Front Cover', - data=image.data + data=data ) ) @@ -123,8 +135,13 @@ def embed_image(): id3.TIT2(text=[tag_to_ascii(track.name, title)], encoding=3)) audio.tags.add( - id3.TPE1(text=[tag_to_ascii(track.artists[0].name, artist)], + id3.TPE1(text=[tag_to_ascii(artists, artists_ascii)], encoding=3)) + if album_artist is not None: + audio.tags.add( + id3.TPE2(text=[tag_to_ascii( + track.album.artist.name, album_artist)], + encoding=3)) audio.tags.add(id3.TDRC(text=[str(track.album.year)], encoding=3)) audio.tags.add( @@ -161,14 +178,14 @@ def set_id3_tags_raw(audio, audio_file): except id3.ID3NoHeaderError: id3_dict = id3.ID3() - def embed_image(): + def embed_image(data): id3_dict.add( id3.APIC( encoding=3, mime='image/jpeg', type=3, desc='Front Cover', - data=image.data + data=data ) ) @@ -182,8 +199,13 @@ def embed_image(): id3.TIT2(text=[tag_to_ascii(track.name, title)], encoding=3)) id3_dict.add( - id3.TPE1(text=[tag_to_ascii(track.artists[0].name, artist)], + id3.TPE1(text=[tag_to_ascii(artists, artists_ascii)], encoding=3)) + if album_artist is not None: + id3_dict.add( + id3.TPE2(text=[tag_to_ascii( + track.album.artist.name, album_artist)], + encoding=3)) id3_dict.add(id3.TDRC(text=[str(track.album.year)], encoding=3)) id3_dict.add( @@ -219,12 +241,12 @@ def set_vorbis_comments(audio): if audio.tags is None: audio.add_tags() - def embed_image(): + def embed_image(data): pic = flac.Picture() pic.type = 3 pic.mime = "image/jpeg" pic.desc = "Front Cover" - pic.data = image.data + pic.data = data if args.output_type == "flac": audio.add_picture(pic) else: @@ -236,7 +258,10 @@ def embed_image(): if album is not None: audio.tags["ALBUM"] = tag_to_ascii(track.album.name, album) audio.tags["TITLE"] = tag_to_ascii(track.name, title) - audio.tags["ARTIST"] = tag_to_ascii(track.artists[0].name, artist) + audio.tags["ARTIST"] = tag_to_ascii(artists, artists_ascii) + if album_artist is not None: + audio.tags["ALBUMARTIST"] = \ + tag_to_ascii(track.album.artist.name, album_artist) audio.tags["DATE"] = str(track.album.year) audio.tags["YEAR"] = str(track.album.year) audio.tags["DISCNUMBER"] = str(track.disc) @@ -261,15 +286,18 @@ def set_mp4_tags(audio): if audio.tags is None: audio.add_tags() - def embed_image(): - audio.tags["covr"] = mp4.MP4Cover(image.data) + def embed_image(data): + audio.tags["covr"] = mp4.MP4Cover(data) save_cover_image(embed_image) if album is not None: audio.tags["\xa9alb"] = tag_to_ascii(track.album.name, album) audio["\xa9nam"] = tag_to_ascii(track.name, title) - audio.tags["\xa9ART"] = tag_to_ascii(track.artists[0].name, artist) + audio.tags["\xa9ART"] = tag_to_ascii(artists, artists_ascii) + if album_artist is not None: + audio.tags["aART"] = \ + tag_to_ascii(track.album.artist.name, album_artist) audio.tags["\xa9day"] = str(track.album.year) audio.tags["disk"] = [(track.disc, num_discs)] audio.tags["trkn"] = [(track.index, num_tracks)] @@ -288,16 +316,18 @@ def set_m4a_tags(audio): # add M4A tags if it doesn't exist audio.add_tags() - def embed_image(): - audio.tags[str("covr")] = m4a.M4ACover(image.data) + def embed_image(data): + audio.tags[str("covr")] = m4a.M4ACover(data) save_cover_image(embed_image) if album is not None: audio.tags[b"\xa9alb"] = tag_to_ascii(track.album.name, album) audio[b"\xa9nam"] = tag_to_ascii(track.name, title) - audio.tags[b"\xa9ART"] = tag_to_ascii( - track.artists[0].name, artist) + audio.tags[b"\xa9ART"] = tag_to_ascii(artists, artists_ascii) + if album_artist is not None: + audio.tags[str("aART")] = tag_to_ascii( + track.album.artist.name, album_artist) audio.tags[b"\xa9day"] = str(track.album.year) audio.tags[str("disk")] = (track.disc, num_discs) audio.tags[str("trkn")] = (track.index, num_tracks) @@ -315,6 +345,9 @@ def embed_image(): if args.output_type == "flac": audio = flac.FLAC(audio_file) set_vorbis_comments(audio) + if args.output_type == "aiff": + audio = aiff.AIFF(audio_file) + set_id3_tags(audio) elif args.output_type == "ogg": audio = oggvorbis.OggVorbis(audio_file) set_vorbis_comments(audio) @@ -374,9 +407,12 @@ def channel_str(num): # log id3 tags print("-" * 79) - print(Fore.YELLOW + "Setting artist: " + artist + Fore.RESET) + print(Fore.YELLOW + "Setting artist: " + artists_ascii + Fore.RESET) if album is not None: print(Fore.YELLOW + "Setting album: " + album + Fore.RESET) + if album_artist is not None: + print(Fore.YELLOW + "Setting album artist: " + album_artist + + Fore.RESET) print(Fore.YELLOW + "Setting title: " + title + Fore.RESET) print(Fore.YELLOW + "Setting track info: (" + str(track.index) + ", " + str(num_tracks) + ")" + Fore.RESET) @@ -407,6 +443,21 @@ def channel_str(num): print(Fore.YELLOW + "Writing Vorbis comments - " + audio.tags.vendor + Fore.RESET) print("-" * 79) + if args.output_type == "aiff": + print("Time: " + format_time(audio.info.length) + + "\tAudio Interchange File Format" + + "\t[ " + bit_rate_str(audio.info.bitrate / 1000) + " @ " + + str(audio.info.sample_rate) + + " Hz - " + channel_str(audio.info.channels) + " ]") + print("-" * 79) + id3_version = "v%d.%d" % ( + audio.tags.version[0], audio.tags.version[1]) + print("ID3 " + id3_version + ": " + + str(len(audio.tags.values())) + " frames") + print( + Fore.YELLOW + "Writing ID3 version " + + id3_version + Fore.RESET) + print("-" * 79) if args.output_type == "alac.m4a": bit_rate = ((audio.info.bits_per_sample * audio.info.sample_rate) * audio.info.channels) diff --git a/spotify_ripper/utils.py b/spotify_ripper/utils.py index 96792e5..4dd3168 100644 --- a/spotify_ripper/utils.py +++ b/spotify_ripper/utils.py @@ -93,7 +93,7 @@ def to_normalized_ascii(_str): def rm_file(file_name): try: - os.remove(file_name) + os.remove(enc_str(file_name)) except OSError as e: # don't need to print a warning if the file doesn't exist if e.errno != errno.ENOENT: @@ -109,13 +109,13 @@ def default_settings_dir(): def settings_dir(): args = get_args() - return norm_path(args.settings[0]) if args.settings is not None \ + return norm_path(args.settings) if args.settings is not None \ else default_settings_dir() def base_dir(): args = get_args() - return norm_path(args.directory[0]) if args.directory is not None \ + return norm_path(args.directory) if args.directory is not None \ else os.getcwd() @@ -161,6 +161,10 @@ def get_playlist_track(track, playlist): return None +def change_file_extension(file_name, ext): + return os.path.splitext(file_name)[0] + "." + ext + + def format_track_string(ripper, format_string, idx, track): args = get_args() current_album = ripper.current_album @@ -168,34 +172,37 @@ def format_track_string(ripper, format_string, idx, track): # this fixes the track.disc if not track.is_loaded: - track.load() + track.load(args.timeout) if not track.album.is_loaded: - track.album.load() + track.album.load(args.timeout) + if current_album is None: + current_album = track.album album_browser = track.album.browse() - album_browser.load() + album_browser.load(args.timeout) track_artist = to_ascii( escape_filename_part(track.artists[0].name)) - track_artists = to_ascii(", ".join( - [artist.name for artist in track.artists])) + track_artists = to_ascii( + escape_filename_part(", ".join( + [artist.name for artist in track.artists]))) if len(track.artists) > 1: - featuring_artists = to_ascii(", ".join( - [artist.name for artist in track.artists[1:]])) + featuring_artists = to_ascii( + escape_filename_part(", ".join( + [artist.name for artist in track.artists[1:]]))) else: featuring_artists = "" album_artist = to_ascii( - current_album.artist.name - if current_album is not None else track_artist) + escape_filename_part(current_album.artist.name)) album_artists_web = track_artists # only retrieve album_artist_web if it exists in the format string - if (current_album is not None and - format_string.find("{album_artists_web}") >= 0): + if format_string.find("{album_artists_web}") >= 0: artist_array = \ ripper.web.get_artists_on_album(current_album.link.uri) if artist_array is not None: - album_artists_web = to_ascii(", ".join(artist_array)) + album_artists_web = to_ascii( + escape_filename_part(", ".join(artist_array))) album = to_ascii(escape_filename_part(track.album.name)) track_name = to_ascii(escape_filename_part(track.name)) @@ -204,6 +211,7 @@ def format_track_string(ripper, format_string, idx, track): idx_str = str(idx + 1) track_num = str(track.index) disc_num = str(track.disc) + track_uri = track.link.uri # calculate num of discs on the album num_discs = 0 @@ -231,7 +239,7 @@ def format_track_string(ripper, format_string, idx, track): if (format_string.find("{copyright}") >= 0 or format_string.find("{label}") >= 0): album_browser = track.album.browse() - album_browser.load() + album_browser.load(args.timeout) if len(album_browser.copyrights) > 0: copyright = escape_filename_part(album_browser.copyrights[0]) label = re.sub(r"^[0-9]+\s+", "", copyright) @@ -285,7 +293,9 @@ def format_track_string(ripper, format_string, idx, track): "playlist_track_add_time": create_time, "track_add_time": create_time, "playlist_track_add_user": creator, - "track_add_user": creator + "track_add_user": creator, + "track_uri": track_uri, + "uri": track_uri } fill_tags = {"idx", "index", "track_num", "track_idx", "track_index", "disc_num", "disc_idx", "disc_index", @@ -345,7 +355,7 @@ def format_track_string(ripper, format_string, idx, track): # returns path of executable def which(program): def is_exe(fpath): - return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + return os.path.isfile(fpath) and os.access(enc_str(fpath), os.X_OK) fpath, fname = os.path.split(program) if fpath: @@ -423,7 +433,7 @@ def format_size(size, short=False): # returns true if audio_file is a partial of track def is_partial(audio_file, track): args = get_args() - if (args.partial_check == "none"): + if args.partial_check == "none": return False def audio_file_duration(audio_file): @@ -436,12 +446,15 @@ def audio_file_duration(audio_file): audio_file_dur = audio_file_duration(audio_file) # for 'weak', give a ~1.5 second wiggle-room - if (args.partial_check == "strict"): + if args.partial_check == "strict": return (audio_file_dur is None or track.duration > (audio_file_dur * 1000)) else: + wiggle_room = max((track.duration * 0.01), 3000) if \ + args.partial_check == "weak" else \ + int(args.partial_check.split(":")[1]) * 1000 return (audio_file_dur is not None and - (track.duration - 1500) > (audio_file_dur * 1000)) + (track.duration - wiggle_room) > (audio_file_dur * 1000)) # borrowed from eyeD3 diff --git a/spotify_ripper/web.py b/spotify_ripper/web.py index 73832d4..11dc062 100644 --- a/spotify_ripper/web.py +++ b/spotify_ripper/web.py @@ -17,13 +17,19 @@ class WebAPI(object): def __init__(self, args, ripper): self.args = args self.ripper = ripper - self.cache = {} + self.cache = { + "albums_with_filter": {}, + "artists_on_album": {}, + "genres": {}, + "charts": {}, + "large_coverart": {} + } - def cache_result(self, uri, result): - self.cache[uri] = result + def cache_result(self, name, uri, result): + self.cache[name][uri] = result - def get_cached_result(self, uri): - return self.cache.get(uri) + def get_cached_result(self, name, uri): + return self.cache[name].get(uri) def request_json(self, url, msg): res = self.request_url(url, msg) @@ -51,10 +57,10 @@ def charts_url(self, url_path): def get_albums_with_filter(self, uri): args = self.args - album_type = ('&album_type=' + args.artist_album_type[0]) \ + album_type = ('&album_type=' + args.artist_album_type) \ if args.artist_album_type is not None else "" - market = ('&market=' + args.artist_album_market[0]) \ + market = ('&market=' + args.artist_album_market) \ if args.artist_album_market is not None else "" def get_albums_json(offset): @@ -65,7 +71,7 @@ def get_albums_json(offset): return self.request_json(url, "albums") # check for cached result - cached_result = self.get_cached_result(uri) + cached_result = self.get_cached_result("albums_with_filter", uri) if cached_result is not None: return cached_result @@ -95,7 +101,7 @@ def get_albums_json(offset): except KeyError as e: break print(str(len(album_uris)) + " albums found") - self.cache_result(uri, album_uris) + self.cache_result("albums_with_filter", uri, album_uris) return album_uris def get_artists_on_album(self, uri): @@ -104,7 +110,7 @@ def get_album_json(album_id): return self.request_json(url, "album") # check for cached result - cached_result = self.get_cached_result(uri) + cached_result = self.get_cached_result("artists_on_album", uri) if cached_result is not None: return cached_result @@ -118,7 +124,7 @@ def get_album_json(album_id): return None result = [artist['name'] for artist in album['artists']] - self.cache_result(uri, result) + self.cache_result("artists_on_album", uri, result) return result # genre_type can be "artist" or "album" @@ -132,7 +138,7 @@ def get_genre_json(spotify_id): uri = item.link.uri # check for cached result - cached_result = self.get_cached_result(uri) + cached_result = self.get_cached_result("genres", uri) if cached_result is not None: return cached_result @@ -145,7 +151,7 @@ def get_genre_json(spotify_id): return None result = json_obj["genres"] - self.cache_result(uri, result) + self.cache_result("genres", uri, result) return result # doesn't seem to be officially supported by Spotify @@ -156,7 +162,7 @@ def get_chart_tracks(metrics, region, time_window, from_date): res = self.request_url(url, region + " " + metrics + " charts") if res is not None: - csv_items = [enc_str(r) for r in res.text.split("\n")] + csv_items = [enc_str(to_ascii(r)) for r in res.text.split("\n")] reader = csv.DictReader(csv_items) return ["spotify:track:" + row["URL"].split("/")[-1] for row in reader] @@ -164,7 +170,7 @@ def get_chart_tracks(metrics, region, time_window, from_date): return [] # check for cached result - cached_result = self.get_cached_result(uri) + cached_result = self.get_cached_result("charts", uri) if cached_result is not None: return cached_result @@ -224,5 +230,42 @@ def sanity_check_date(val): "tracks": tracks_obj } - self.cache_result(uri, charts_obj) + self.cache_result("charts", uri, charts_obj) return charts_obj + + + def get_large_coverart(self, uri): + def get_track_json(track_id): + url = self.api_url('tracks/' + track_id) + return self.request_json(url, "track") + + def get_image_data(url): + response = self.request_url(url, "cover art") + return response.content + + # check for cached result + cached_result = self.get_cached_result("large_coverart", uri) + if cached_result is not None: + return get_image_data(cached_result) + + # extract album id from uri + uri_tokens = uri.split(':') + if len(uri_tokens) != 3: + return None + + track = get_track_json(uri_tokens[2]) + if track is None: + return None + + try: + images = track['album']['images'] + except KeyError: + return None + + for image in images: + if image["width"] == 640: + self.cache_result("large_coverart", uri, image["url"]) + return get_image_data(image["url"]) + + return None +