diff --git a/README.md b/README.md index 8f55330..1d5d4b1 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,59 @@ spotifyripper ============= -small ripper script for spotify (rips playlists to mp3 and includes ID3 tags) +small ripper script for spotify (rips playlists to mp3 and includes ID3 tags and album covers) note that stream ripping violates the ToC's of libspotify! -usage ------ - ./jbripper.py [username] [password] [spotify_url] - -examples --------- - "./jbripper.py user pass spotify:track:52xaypL0Kjzk0ngwv3oBPR" creates "Beat It.mp3" file - "./jbripper.py user pass spotify:user:[user]:playlist:7HC9PMdSbwGBBn3EVTaCNx rips entire playlist - -features +Usage: -------- -* real-time VBR ripping from spotify PCM stream -* writes id3 tags (including album covers) - -* creates files and directories based on the following structure artist/album/song.mp3 + usage: jbripper [-h] -u USER -p PASSWORD -U URL [-l [LIBRARY]] [-O OUTPUTDIR] + [-P] [-V VBR] [-I] [-f | -d] + + Rip Spotify songs + + optional arguments: + -h, --help show this help message and exit + -u USER, --user USER spotify user + -p PASSWORD, --password PASSWORD + spotify password + -U URL, --url URL spotify url + -l [LIBRARY], --library [LIBRARY] + music library path + -O OUTPUTDIR, --outputdir OUTPUTDIR + music output dir (default is current working directory) + -P, --playback set if you want to listen to the tracks that are currently ripped (start with "padsp ./jbripper.py ..." if using pulse audio) + -V VBR, --vbr VBR Lame VBR quality setting. Equivalent to Lame -V parameter. Default 0 + -I, --ignoreerrors Ignore encountered errors by skipping to next track in playlist + -o, --oldtags set to write ID3v2 tags version 2.3.0 instead of newer version 2.4.0 + -f, --file Save output mp3 file with the following format: "Artist - Song - [ Album ].mp3" (default) + -d, --directory Save output mp3 to a directory with the following format: "Artist/Album/Song.mp3" + + Example usage: + rip a single file: ./jbripper.py -u user -p password -U spotify:track:52xaypL0Kjzk0ngwv3oBPR + rip entire playlist: ./jbripper.py -u user -p password -U spotify:user:username:playlist:4vkGNcsS8lRXj4q945NIA4 + check if file exists before ripping: ./jbripper.py -u user -p password -U spotify:track:52xaypL0Kjzk0ngwv3oBPR -l ~/Music + + +features: +---------- + +- real-time VBR ripping from spotify PCM stream +- writes id3 tags (including album cover) +- Check for existing songs prerequisites: --------------- -* libspotify (download at https://developer.spotify.com/technologies/libspotify/) - -* pyspotify (sudo pip install -U pyspotify, requires python-dev) - -* spotify binary appkey (download at developer.spotify.com and copy to wd, requires premium!) - -* lame (sudo apt-get install lame) - -* eyeD3 (pip install eyeD3) - -TODO ----- -- [ ] skip exisiting track (avoid / completed tracks / completed = successful id3) +--------------- +- Python 2 (if P3 is also install change env to python2 and use pip2) +- libspotify (download at https://developer.spotify.com/technologies/libspotify/) +- pyspotify (sudo pip install -U pyspotify) +- spotify appkey (download at developer.spotify.com, requires Spotify Premium) +- jukebox.py (pyspotify example) +- lame (sudo apt-get install lame) +- eyeD3 (pip install eyeD3) + +TODO: +------ - [ ] detect if other spotify instance is interrupting -- [ ] add album supprt : spotify:album:1UnRYaeCev9JVKEHWBEgHe diff --git a/jbripper.py b/jbripper.py index d6fa86d..cd8c140 100755 --- a/jbripper.py +++ b/jbripper.py @@ -1,83 +1,186 @@ #!/usr/bin/env python -# -*- coding: utf8 -*- +# -*- coding: utf-8 -*- from subprocess import call, Popen, PIPE -from spotify import Link, Image +from spotify import Link, Image, AlbumBrowser, ArtistBrowser from jukebox import Jukebox, container_loaded -import os, sys +import os +import sys +import argparse import threading import time +import re -playback = False # set if you want to listen to the tracks that are currently ripped (start with "padsp ./jbripper.py ..." if using pulse audio) +# Music library imports +import fnmatch +import eyed3 +import collections + +# playback = False # set if you want to listen to the tracks that are currently ripped (start with "padsp ./jbripper.py ..." if using pulse audio) pipe = None ripping = False end_of_track = threading.Event() +interrupt = threading.Event() + +musiclibrary = None +args = None + def printstr(str): # print without newline sys.stdout.write(str) sys.stdout.flush() -def shell(cmdline): # execute shell commands (unicode support) - call(cmdline, shell=True) -def rip_init(session, track): - global pipe, ripping - num_track = "%02d" % (track.index(),) - mp3file = track.name()+".mp3" - directory = os.getcwd() + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" +def escape_filename_part(part): + part = re.sub(r"\s*/\s*", r' & ', part) + part = re.sub(r"""\s*[\\/:"*?<>|]+\s*""", r' ', part) + part = part.strip() + part = re.sub(r"(^\.+\s*|(?<=\.)\.+|\s*\.+$)", r'', part) + return part + + +def create_filepath(outputdir, artist, album, title): + if args.directory is True: + directory = os.path.join(outputdir, escape_filename_part(artist), escape_filename_part(album)) + mp3file = escape_filename_part(title) + ".mp3" + else: + directory = outputdir + mp3file = escape_filename_part(artist) + " - " + escape_filename_part(title) + " - [ " + escape_filename_part(album) + " ].mp3" + if not os.path.exists(directory): os.makedirs(directory) - printstr("ripping " + mp3file + " ...") - p = Popen("lame --silent -V2 -h -r - \""+ directory + mp3file+"\"", stdin=PIPE, shell=True) + + filepath = os.path.join(directory, mp3file) + return filepath + + +def rip_init(session, track, outputdir): + global pipe, ripping + num_track = "%02d" % (track.index(),) + artist = artist = ', '.join(a.name() for a in track.artists()) + album = track.album().name() + title = track.name() + + filepath = create_filepath(outputdir, artist, album, title) + + printstr("ripping " + filepath + " ...\n") + p = Popen(["lame", "--silent", "-V" + args.vbr, "-h", "-r", "-", filepath], stdin=PIPE) pipe = p.stdin ripping = True + def rip_terminate(session, track): global ripping if pipe is not None: print(' done!') + #Avoid concurrent operation exceptions + if args.playback: + time.sleep(1) pipe.close() - ripping = False + ripping = False + +def rip_delete(track, outputdir): + artist = artist = ', '.join(a.name() for a in track.artists()) + album = track.album().name() + title = track.name() + filepath = create_filepath(outputdir, artist, album, title) + time.sleep(1) + print("Deleting partially ripped file at " + filepath) + call(["rm", "-f", filepath]) def rip(session, frames, frame_size, num_frames, sample_type, sample_rate, channels): if ripping: printstr('.') - pipe.write(frames); + pipe.write(frames) -def rip_id3(session, track): # write ID3 data - num_track = "%02d" % (track.index(),) - mp3file = track.name()+".mp3" - artist = track.artists()[0].name() + +def rip_id3(session, track, outputdir): # write ID3 data + num_track = "%02d" % (track.index(), ) + artist = artist = ', '.join(a.name() for a in track.artists()) album = track.album().name() title = track.name() year = track.album().year() - directory = os.getcwd() + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" + + filepath = create_filepath(outputdir, artist, album, title) + + # remember that we downloaded this song + musiclibrary[artist][album][title] = filepath # download cover - image = session.image_create(track.album().cover()) - while not image.is_loaded(): # does not work from MainThread! - time.sleep(0.1) - fh_cover = open('cover.jpg','wb') - fh_cover.write(image.data()) - fh_cover.close() - - # write id3 data - cmd = "eyeD3" + \ - " --add-image cover.jpg:FRONT_COVER" + \ - " -t \"" + title + "\"" + \ - " -a \"" + artist + "\"" + \ - " -A \"" + album + "\"" + \ - " -n " + str(num_track) + \ - " -Y " + str(year) + \ - " -Q " + \ - " \"" + directory + mp3file + "\"" - shell(cmd) + coverFound = False + cover = track.album().cover() + if cover is not None: + image = session.image_create(cover) + if image is not None: + while not image.is_loaded(): # does not work from MainThread! + time.sleep(0.1) + fh_cover = open('cover.jpg','wb') + fh_cover.write(image.data()) + fh_cover.close() + coverFound = True + # write ID3 data + if coverFound: + if args.oldtags: + call(["eyeD3", "--to-v2.3", "--add-image", "cover.jpg:FRONT_COVER", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", filepath]) + else: + call(["eyeD3", "--add-image", "cover.jpg:FRONT_COVER", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", filepath]) + else: + if args.oldtags: + call(["eyeD3", "--to-v2.3", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", filepath]) + else: + call(["eyeD3", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", filepath]) + + print(filepath + " written") # delete cover - shell("rm -f cover.jpg") + call(["rm", "-f", "cover.jpg"]) + + +def library_scan(path): + print("Scanning " + path) + count = 0 + tree = lambda: collections.defaultdict(tree) + musiclibrary = tree() + for root, dirnames, filenames in os.walk(path): + for filename in fnmatch.filter(filenames, '*.mp3'): + filepath = os.path.join(root, filename) + try: + audiofile = eyed3.load(filepath) + try: + artist=audiofile.tag.artist + except AttributeError: + artist="" + try: + album=audiofile.tag.album + except AttributeError: + album="" + try: + title=audiofile.tag.title + except AttributeError: + title="" + + musiclibrary[artist][album][title]=filepath + count += 1 + + except Exception, e: + print("Error loading " + filepath) + print(e) + print(str(count) + " mp3 files found") + return musiclibrary + + +def library_track_exists(track, outputdir): + artist = artist = ', '.join(a.name() for a in track.artists()) + album = track.album().name() + title = track.name() + filepathfrominfo = create_filepath(outputdir, artist, album, title) + + return (musiclibrary is not None and musiclibrary[artist][album][title]) or (os.path.exists(filepathfrominfo) and filepathfrominfo) + class RipperThread(threading.Thread): + def __init__(self, ripper): threading.Thread.__init__(self) self.ripper = ripper @@ -87,46 +190,108 @@ def run(self): container_loaded.wait() container_loaded.clear() + # output dir + outputdir = os.getcwd() + if args.outputdir != None: + outputdir = os.path.normpath(os.path.realpath(args.outputdir[0])) + + session = self.ripper.session + # create track iterator - link = Link.from_string(sys.argv[3]) + link = Link.from_string(args.url[0]) if link.type() == Link.LINK_TRACK: track = link.as_track() itrack = iter([track]) - elif link.type() == Link.LINK_PLAYLIST: + elif link.type() == Link.LINK_PLAYLIST or link.type() == Link.LINK_STARRED: playlist = link.as_playlist() print('loading playlist ...') while not playlist.is_loaded(): - time.sleep(0.1) + print(' pending playlist ') + time.sleep(0.5) print('done') itrack = iter(playlist) + elif link.type() == Link.LINK_ALBUM: + album = AlbumBrowser(link.as_album()) + print('loading album ...') + while not album.is_loaded(): + print(' pending album ') + time.sleep(0.5) + print('done') + itrack = iter(album) + elif link.type() == Link.LINK_ARTIST: + artist = ArtistBrowser(link.as_artist()) + print('loading artist') + while not artist.is_loaded(): + print(' pending artist ') + time.sleep(0.5) + print('done') + itrack = iter(artist) + # ripping loop - session = self.ripper.session + count = 0 for track in itrack: - - self.ripper.load_track(track) - - rip_init(session, track) + count += 1 + # if the track is not loaded, track.availability is not ready + try: + self.ripper.load_track(track) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as inst: + if not args.ignoreerrors: + raise + print("Unexpected error: ", type(inst)) + print(inst) + print("Skipping to next track, if in playlist") + continue + if interrupt.isSet(): + break + while not track.is_loaded(): + time.sleep(0.1) + if track.availability() != 1: + print('Skipping. Track not available') + else: + #self.ripper.load_track(track) + exists = library_track_exists(track, outputdir) + if exists: + print("Skipping. Track found at " + exists) + else: + try: + rip_init(session, track, outputdir) - self.ripper.play() + self.ripper.play() - end_of_track.wait() - end_of_track.clear() # TODO check if necessary + end_of_track.wait() + end_of_track.clear() # TODO check if necessary - rip_terminate(session, track) - rip_id3(session, track) + rip_terminate(session, track) + if interrupt.isSet(): + rip_delete(track, outputdir) + break + rip_id3(session, track, outputdir) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as inst: + if not args.ignoreerrors: + raise + print("Unexpected error: ", type(inst)) + print(inst) + print("Skipping to next track, if in playlist") self.ripper.disconnect() + class Ripper(Jukebox): + def __init__(self, *a, **kw): Jukebox.__init__(self, *a, **kw) self.ui = RipperThread(self) # replace JukeboxUI - self.session.set_preferred_bitrate(2) # 320 bps + self.session.set_preferred_bitrate(1) # 320 kbps def music_delivery_safe(self, session, frames, frame_size, num_frames, sample_type, sample_rate, channels): rip(session, frames, frame_size, num_frames, sample_type, sample_rate, channels) - if playback: + #if playback: + if args.playback: return Jukebox.music_delivery_safe(self, session, frames, frame_size, num_frames, sample_type, sample_rate, channels) else: return num_frames @@ -135,14 +300,46 @@ def end_of_track(self, session): Jukebox.end_of_track(self, session) end_of_track.set() + def abort_play(self): + interrupt.set() + self.stop() + end_of_track.set() + if __name__ == '__main__': - if len(sys.argv) >= 3: - ripper = Ripper(sys.argv[1],sys.argv[2]) # login - ripper.connect() - else: - print "usage : \n" - print " ./jbripper.py [username] [password] [spotify_url]" - print "example : \n" - print " ./jbripper.py user pass spotify:track:52xaypL0Kjzk0ngwv3oBPR - for a single file" - print " ./jbripper.py user pass spotify:user:username:playlist:4vkGNcsS8lRXj4q945NIA4 - rips entire playlist" + + parser = argparse.ArgumentParser(prog='jbripper', + description='Rip Spotify songs', + formatter_class=argparse.RawTextHelpFormatter, + epilog='''Example usage: + rip a single file: ./jbripper.py -u user -p password -U spotify:track:52xaypL0Kjzk0ngwv3oBPR + rip entire playlist: ./jbripper.py -u user -p password -U spotify:user:username:playlist:4vkGNcsS8lRXj4q945NIA4 + check if file exists before ripping: ./jbripper.py -u user -p password -U spotify:track:52xaypL0Kjzk0ngwv3oBPR -l ~/Music + ''') + parser.add_argument('-u', '--user', nargs=1, required=True, help='spotify user') + parser.add_argument('-p', '--password', nargs=1, required=True, help='spotify password') + parser.add_argument('-U', '--url', nargs=1, required=True, help='spotify url') + parser.add_argument('-l', '--library', nargs='?', help='music library path') + parser.add_argument('-O', '--outputdir', nargs=1, help='music output dir (default is current working directory)') + parser.add_argument('-P', '--playback', action='store_true', help='set if you want to listen to the tracks that are currently ripped (start with "padsp ./jbripper.py ..." if using pulse audio)') + parser.add_argument('-V', '--vbr', default='0', help='Lame VBR quality setting. Equivalent to Lame -V parameter. Default 0') + parser.add_argument('-I', '--ignoreerrors', default=False, action='store_true', help='Ignore encountered errors by skipping to next track in playlist') + parser.add_argument('-o', '--oldtags', default=False, action='store_true', help='set to write ID3v2 tags version 2.3.0 instead of newer version 2.4.0') + group = parser.add_mutually_exclusive_group(required=False) + group.add_argument('-f', '--file', default=True, action="store_true", help='Save output mp3 file with the following format: "Artist - Song - [ Album ].mp3" (default)') + group.add_argument('-d', '--directory', default=False, action="store_true", help='Save output mp3 to a directory with the following format: "Artist/Album/Song.mp3"') + + args = parser.parse_args() + #print args + if args.library != None: + musiclibrary = library_scan(args.library) + else: + tree = lambda: collections.defaultdict(tree) + musiclibrary = tree() + ripper = Ripper(args.user[0], args.password[0]) # login + try: + ripper.connect() + except KeyboardInterrupt: + print("") + print("Aborting (KeyboardInterrupt)") + ripper.abort_play()