From b878bb82b53d308be780f7bc54642819061887c7 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Fri, 17 Jan 2014 13:09:38 +0100 Subject: [PATCH 01/15] add output folder, skip already ripped --- jbripper.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/jbripper.py b/jbripper.py index d6fa86d..5e2ebac 100755 --- a/jbripper.py +++ b/jbripper.py @@ -21,11 +21,17 @@ def printstr(str): # print without newline def shell(cmdline): # execute shell commands (unicode support) call(cmdline, shell=True) -def rip_init(session, track): +def already_ripped(track, basedir): + mp3file = track.name() + ".mp3" + directory = basedir + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" + # check if the file already exists and return the answer + return os.path.exists(directory + mp3file) + +def rip_init(session, track, basedir): global pipe, ripping num_track = "%02d" % (track.index(),) - mp3file = track.name()+".mp3" - directory = os.getcwd() + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" + mp3file = track.name() + ".mp3" + directory = basedir + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" if not os.path.exists(directory): os.makedirs(directory) printstr("ripping " + mp3file + " ...") @@ -45,14 +51,14 @@ def rip(session, frames, frame_size, num_frames, sample_type, sample_rate, chann printstr('.') pipe.write(frames); -def rip_id3(session, track): # write ID3 data +def rip_id3(session, track, basedir): # write ID3 data num_track = "%02d" % (track.index(),) mp3file = track.name()+".mp3" artist = track.artists()[0].name() album = track.album().name() title = track.name() year = track.album().year() - directory = os.getcwd() + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" + directory = basedir + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" # download cover image = session.image_create(track.album().cover()) @@ -87,6 +93,11 @@ def run(self): container_loaded.wait() container_loaded.clear() + # base dir + basedir = os.getcwd() + if len(sys.argv) >= 5: + basedir = sys.argv[4] + # create track iterator link = Link.from_string(sys.argv[3]) if link.type() == Link.LINK_TRACK: @@ -106,7 +117,10 @@ def run(self): self.ripper.load_track(track) - rip_init(session, track) + if already_ripped(track, basedir): + continue + + rip_init(session, track, basedir) self.ripper.play() @@ -114,7 +128,7 @@ def run(self): end_of_track.clear() # TODO check if necessary rip_terminate(session, track) - rip_id3(session, track) + rip_id3(session, track, basedir) self.ripper.disconnect() @@ -137,12 +151,13 @@ def end_of_track(self, session): if __name__ == '__main__': - if len(sys.argv) >= 3: + if len(sys.argv) >= 4: ripper = Ripper(sys.argv[1],sys.argv[2]) # login ripper.connect() else: print "usage : \n" - print " ./jbripper.py [username] [password] [spotify_url]" + print " ./jbripper.py [output_dir]" 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" + From 7da6a1a94441a6747e2f5fe97ce8415825d8b71a Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Sat, 15 Feb 2014 13:26:46 +0100 Subject: [PATCH 02/15] Merged with madrover/spotifyripper. Escaping file names and shell commands properly. Added optional output directory. Added --ignoreerrors (skip to next track in playlist on error). --- README.md | 72 +++++++++++------- jbripper.py | 210 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 197 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 8f55330..d54bf89 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,58 @@ 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 + -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) +- 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) -* 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) +TODO: +------ - [ ] detect if other spotify instance is interrupting - [ ] add album supprt : spotify:album:1UnRYaeCev9JVKEHWBEgHe - diff --git a/jbripper.py b/jbripper.py index 5e2ebac..d8a531e 100755 --- a/jbripper.py +++ b/jbripper.py @@ -4,38 +4,52 @@ from subprocess import call, Popen, PIPE from spotify import Link, Image from jukebox import Jukebox, container_loaded -import os, sys +import os, sys, 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() +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 already_ripped(track, basedir): - mp3file = track.name() + ".mp3" - directory = basedir + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" - # check if the file already exists and return the answer - return os.path.exists(directory + mp3file) +def escape_filename_part(part): + part = re.sub(r"\s*/\s*", r' & ', part) + part = re.sub(r"""\s*[\\/:"*?<>|]+\s*""", r' ', part) + return part -def rip_init(session, track, basedir): +def rip_init(session, track, outputdir): global pipe, ripping num_track = "%02d" % (track.index(),) - mp3file = track.name() + ".mp3" - directory = basedir + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" + artist = artist = ', '.join(a.name() for a in track.artists()) + album = track.album().name() + title = track.name() + + if args.directory is True: + directory = 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) + printstr("ripping " + directory + mp3file + " ...\n") + p = Popen(["lame", "--silent", "-V" + args.vbr, "-h", "-r", "-", directory + mp3file], stdin=PIPE) pipe = p.stdin ripping = True @@ -43,22 +57,30 @@ 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(session, frames, frame_size, num_frames, sample_type, sample_rate, channels): if ripping: printstr('.') pipe.write(frames); -def rip_id3(session, track, basedir): # write ID3 data +def rip_id3(session, track, outputdir): # write ID3 data num_track = "%02d" % (track.index(),) - mp3file = track.name()+".mp3" - artist = track.artists()[0].name() + artist = artist = ', '.join(a.name() for a in track.artists()) album = track.album().name() title = track.name() year = track.album().year() - directory = basedir + "/" + track.artists()[0].name() + "/" + track.album().name() + "/" + + if args.directory is True: + directory = 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" # download cover image = session.image_create(track.album().cover()) @@ -69,19 +91,60 @@ def rip_id3(session, track, basedir): # write ID3 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) - + call(["eyeD3", "--add-image", "cover.jpg:FRONT_COVER", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", directory + mp3file]) + print directory + mp3file + " 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): + if musiclibrary == None: + return False + + artist = artist = ', '.join(a.name() for a in track.artists()) + album = track.album().name() + title = track.name() + + filepath = musiclibrary[artist][album][title] + if filepath == {}: + return False + else: + print "Skipping. Track found at " + filepath + return True + class RipperThread(threading.Thread): def __init__(self, ripper): @@ -93,13 +156,13 @@ def run(self): container_loaded.wait() container_loaded.clear() - # base dir - basedir = os.getcwd() - if len(sys.argv) >= 5: - basedir = sys.argv[4] + # output dir + outputdir = os.getcwd() + if args.outputdir != None: + outputdir = os.path.normpath(os.path.realpath(args.outputdir[0])) # 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]) @@ -113,22 +176,37 @@ def run(self): # ripping loop session = self.ripper.session + count = 0 for track in itrack: - + count += 1 + # if the track is not loaded, track.availability is not ready self.ripper.load_track(track) + while not track.is_loaded(): + time.sleep(0.1) + if track.availability() != 1: + print 'Skipping. Track not available' + else: + #self.ripper.load_track(track) - if already_ripped(track, basedir): - continue + if not library_track_exists(track): + try: + rip_init(session, track, outputdir) - rip_init(session, track, basedir) + 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, basedir) + rip_terminate(session, track) + 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() @@ -140,7 +218,8 @@ def __init__(self, *a, **kw): 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 @@ -151,13 +230,30 @@ def end_of_track(self, session): if __name__ == '__main__': - if len(sys.argv) >= 4: - ripper = Ripper(sys.argv[1],sys.argv[2]) # login - ripper.connect() - else: - print "usage : \n" - print " ./jbripper.py [output_dir]" - 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') + 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) + ripper = Ripper(args.user[0], args.password[0]) # login + ripper.connect() From 5af0b5d2e6170ed439d7a9bb8ef88e25edb09a99 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Sat, 15 Feb 2014 20:19:19 +0100 Subject: [PATCH 03/15] Strip whitespace from filenames (after replacing some characters with space) --- jbripper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jbripper.py b/jbripper.py index d8a531e..a0bf00f 100755 --- a/jbripper.py +++ b/jbripper.py @@ -30,6 +30,7 @@ def printstr(str): # print without newline def escape_filename_part(part): part = re.sub(r"\s*/\s*", r' & ', part) part = re.sub(r"""\s*[\\/:"*?<>|]+\s*""", r' ', part) + part = part.strip() return part def rip_init(session, track, outputdir): From e1db24cdcc5a989a98a09c3aebb530d68c9ce225 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Sun, 16 Feb 2014 00:00:21 +0100 Subject: [PATCH 04/15] Add support for ID3v2 tags ver 2.3.0, remove trailing dots from filenames --- README.md | 1 + jbripper.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d54bf89..2a86b68 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Usage: -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" diff --git a/jbripper.py b/jbripper.py index a0bf00f..a5b5125 100755 --- a/jbripper.py +++ b/jbripper.py @@ -31,6 +31,7 @@ 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 rip_init(session, track, outputdir): @@ -91,8 +92,11 @@ def rip_id3(session, track, outputdir): # write ID3 data fh_cover.write(image.data()) fh_cover.close() - # write id3 data - call(["eyeD3", "--add-image", "cover.jpg:FRONT_COVER", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", directory + mp3file]) + # write ID3 data + 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", directory + mp3file]) + else: + call(["eyeD3", "--add-image", "cover.jpg:FRONT_COVER", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", directory + mp3file]) print directory + mp3file + " written" # delete cover call(["rm", "-f", "cover.jpg"]) @@ -248,6 +252,7 @@ def end_of_track(self, session): 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"') From 591f788bdf3d0e1070ee367ba1a35af28e3de717 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Sun, 16 Feb 2014 19:32:53 +0100 Subject: [PATCH 05/15] remember downloaded songs same session, some formatting --- jbripper.py | 110 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 43 deletions(-) diff --git a/jbripper.py b/jbripper.py index a5b5125..d4e5271 100755 --- a/jbripper.py +++ b/jbripper.py @@ -1,20 +1,22 @@ #!/usr/bin/env python -# -*- coding: utf8 -*- +# -*- coding: utf-8 -*- from subprocess import call, Popen, PIPE from spotify import Link, Image from jukebox import Jukebox, container_loaded -import os, sys, argparse +import os +import sys +import argparse import threading import time import re -#Music library imports +# 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) +# 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 @@ -23,10 +25,12 @@ musiclibrary = None args = None + def printstr(str): # print without newline sys.stdout.write(str) sys.stdout.flush() + def escape_filename_part(part): part = re.sub(r"\s*/\s*", r' & ', part) part = re.sub(r"""\s*[\\/:"*?<>|]+\s*""", r' ', part) @@ -34,27 +38,37 @@ def escape_filename_part(part): part = re.sub(r"(^\.+\s*|(?<=\.)\.+|\s*\.+$)", r' ', part) return part -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() +def create_filepath(outputdir, artist, album, title): if args.directory is True: - directory = outputdir + "/" + escape_filename_part(artist) + "/" + escape_filename_part(album) + "/" + directory = os.path.join(outputdir, escape_filename_part(artist), escape_filename_part(album)) mp3file = escape_filename_part(title) + ".mp3" else: - directory = outputdir + "/" + 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 " + directory + mp3file + " ...\n") - p = Popen(["lame", "--silent", "-V" + args.vbr, "-h", "-r", "-", directory + mp3file], stdin=PIPE) + + 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: @@ -65,52 +79,57 @@ def rip_terminate(session, track): pipe.close() ripping = False + 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, outputdir): # write ID3 data - num_track = "%02d" % (track.index(),) + 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() - if args.directory is True: - directory = 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" + 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 = open('cover.jpg', 'wb') fh_cover.write(image.data()) fh_cover.close() # write ID3 data 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", directory + mp3file]) + 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", directory + mp3file]) - print directory + mp3file + " written" + call(["eyeD3", "--add-image", "cover.jpg:FRONT_COVER", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", filepath]) + print(filepath + " written") # delete cover call(["rm", "-f", "cover.jpg"]) + + + + + def library_scan(path): - print "Scanning " + 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 ) + filepath = os.path.join(root, filename) try: audiofile = eyed3.load(filepath) try: @@ -130,9 +149,9 @@ def library_scan(path): count += 1 except Exception, e: - print "Error loading " + filepath - print e - print str(count) + " mp3 files found" + print("Error loading " + filepath) + print(e) + print(str(count) + " mp3 files found") return musiclibrary def library_track_exists(track): @@ -147,7 +166,7 @@ def library_track_exists(track): if filepath == {}: return False else: - print "Skipping. Track found at " + filepath + print("Skipping. Track found at " + filepath) return True @@ -189,7 +208,7 @@ def run(self): while not track.is_loaded(): time.sleep(0.1) if track.availability() != 1: - print 'Skipping. Track not available' + print('Skipping. Track not available') else: #self.ripper.load_track(track) @@ -209,13 +228,15 @@ def run(self): except Exception as inst: if not args.ignoreerrors: raise - print "Unexpected error: ", type(inst) - print inst - print "Skipping to next track, if in playlist" + 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 @@ -244,15 +265,15 @@ def end_of_track(self, session): 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('-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') + 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"') @@ -261,5 +282,8 @@ def end_of_track(self, session): #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 ripper.connect() From ae3e54ebd58652ebc3147458cedf15b614dcf756 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Sun, 16 Feb 2014 23:02:59 +0100 Subject: [PATCH 06/15] skip already existing mp3 files --- jbripper.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/jbripper.py b/jbripper.py index d4e5271..b3fe582 100755 --- a/jbripper.py +++ b/jbripper.py @@ -116,13 +116,7 @@ def rip_id3(session, track, outputdir): # write ID3 data call(["rm", "-f", "cover.jpg"]) - - - - - def library_scan(path): - print("Scanning " + path) count = 0 tree = lambda: collections.defaultdict(tree) @@ -154,23 +148,18 @@ def library_scan(path): print(str(count) + " mp3 files found") return musiclibrary -def library_track_exists(track): - if musiclibrary == None: - return False +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) - filepath = musiclibrary[artist][album][title] - if filepath == {}: - return False - else: - print("Skipping. Track found at " + filepath) - return True + 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 @@ -211,8 +200,10 @@ def run(self): print('Skipping. Track not available') else: #self.ripper.load_track(track) - - if not library_track_exists(track): + exists = library_track_exists(track, outputdir) + if exists: + print("Skipping. Track found at " + exists) + else: try: rip_init(session, track, outputdir) From dc50fa303c2361442d4319ae7c3fa2e405b1e768 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Mon, 17 Feb 2014 00:21:52 +0100 Subject: [PATCH 07/15] Strip whitespace from filenames fix (after replacing some characters with space) --- jbripper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jbripper.py b/jbripper.py index b3fe582..a7d95ba 100755 --- a/jbripper.py +++ b/jbripper.py @@ -35,7 +35,7 @@ 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) + part = re.sub(r"(^\.+\s*|(?<=\.)\.+|\s*\.+$)", r'', part) return part From e76f51be500871ee1d6b3755b37b203f99964722 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Mon, 17 Feb 2014 11:39:24 +0100 Subject: [PATCH 08/15] gracefully handle KeyboardInterrupt (Ctrl+C) --- jbripper.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/jbripper.py b/jbripper.py index a7d95ba..dd6e396 100755 --- a/jbripper.py +++ b/jbripper.py @@ -21,6 +21,7 @@ pipe = None ripping = False end_of_track = threading.Event() +interrupt = threading.Event() musiclibrary = None args = None @@ -79,6 +80,14 @@ def rip_terminate(session, track): pipe.close() 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: @@ -194,6 +203,8 @@ def run(self): count += 1 # if the track is not loaded, track.availability is not ready self.ripper.load_track(track) + if interrupt.isSet(): + break while not track.is_loaded(): time.sleep(0.1) if track.availability() != 1: @@ -213,6 +224,9 @@ def run(self): end_of_track.clear() # TODO check if necessary rip_terminate(session, track) + if interrupt.isSet(): + rip_delete(track, outputdir) + break rip_id3(session, track, outputdir) except (KeyboardInterrupt, SystemExit): raise @@ -245,6 +259,11 @@ 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__': @@ -277,4 +296,9 @@ def end_of_track(self, session): tree = lambda: collections.defaultdict(tree) musiclibrary = tree() ripper = Ripper(args.user[0], args.password[0]) # login - ripper.connect() + try: + ripper.connect() + except KeyboardInterrupt: + print("") + print("Aborting (KeyboardInterrupt)") + ripper.abort_play() From 64e71b0c5425bedd5587db3d09d6e03d1f305e62 Mon Sep 17 00:00:00 2001 From: wolkenschieber Date: Thu, 25 Dec 2014 11:18:13 +0100 Subject: [PATCH 09/15] Added link types album and artist - Added link types album and artist - Cover downloading only if exists --- jbripper.py | 55 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/jbripper.py b/jbripper.py index dd6e396..989b845 100755 --- a/jbripper.py +++ b/jbripper.py @@ -2,7 +2,7 @@ # -*- 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 import sys @@ -108,18 +108,30 @@ def rip_id3(session, track, outputdir): # write ID3 data 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() + 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 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]) + 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: - call(["eyeD3", "--add-image", "cover.jpg:FRONT_COVER", "-t", title, "-a", artist, "-A", album, "-n", str(num_track), "-Y", str(year), "-Q", filepath]) + 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 call(["rm", "-f", "cover.jpg"]) @@ -183,6 +195,8 @@ def run(self): 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(args.url[0]) if link.type() == Link.LINK_TRACK: @@ -192,12 +206,29 @@ def run(self): 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: count += 1 From 560723145781a2667a2c935655557fe80c7e61dd Mon Sep 17 00:00:00 2001 From: wolkenschieber Date: Thu, 25 Dec 2014 11:32:57 +0100 Subject: [PATCH 10/15] Added starred playlists --- jbripper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jbripper.py b/jbripper.py index 989b845..22fc501 100755 --- a/jbripper.py +++ b/jbripper.py @@ -202,7 +202,7 @@ def run(self): 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(): From 866f7e560ff04c6d5d7a003083184b3cdc1a896f Mon Sep 17 00:00:00 2001 From: wolkenschieber Date: Thu, 25 Dec 2014 11:34:06 +0100 Subject: [PATCH 11/15] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a86b68..1d5d4b1 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ features: prerequisites: --------------- - +- 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) @@ -56,4 +56,4 @@ prerequisites: TODO: ------ - [ ] detect if other spotify instance is interrupting -- [ ] add album supprt : spotify:album:1UnRYaeCev9JVKEHWBEgHe + From 3b4a3febe394a3d074ff2a259effcb54400a57d7 Mon Sep 17 00:00:00 2001 From: wolkenschieber Date: Thu, 25 Dec 2014 18:49:14 +0100 Subject: [PATCH 12/15] Set preferred bitrate to 320kps @see api.h of pyspotify lilbrary --- jbripper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jbripper.py b/jbripper.py index 22fc501..ede11b2 100755 --- a/jbripper.py +++ b/jbripper.py @@ -276,7 +276,7 @@ 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 bps 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) From 5bb00319e3517a1a5c2113ed1748f7466eddfa61 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Tue, 6 Jun 2017 23:42:14 +0200 Subject: [PATCH 13/15] Merged commit fixing quality --- jbripper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jbripper.py b/jbripper.py index ede11b2..71aa5fa 100755 --- a/jbripper.py +++ b/jbripper.py @@ -276,7 +276,7 @@ class Ripper(Jukebox): def __init__(self, *a, **kw): Jukebox.__init__(self, *a, **kw) self.ui = RipperThread(self) # replace JukeboxUI - self.session.set_preferred_bitrate(1) # 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) From a36ba3387080fcd16d08498399d0bc765f0a6aac Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Tue, 6 Jun 2017 23:44:05 +0200 Subject: [PATCH 14/15] Fixes #2 --- jbripper.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/jbripper.py b/jbripper.py index 71aa5fa..93ea0f2 100755 --- a/jbripper.py +++ b/jbripper.py @@ -233,7 +233,16 @@ def run(self): for track in itrack: count += 1 # if the track is not loaded, track.availability is not ready - self.ripper.load_track(track) + 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") if interrupt.isSet(): break while not track.is_loaded(): From 40d047586e5b3758e52a9c92e131906ac4fd91f3 Mon Sep 17 00:00:00 2001 From: AndersTornkvist Date: Tue, 6 Jun 2017 23:57:47 +0200 Subject: [PATCH 15/15] Skipping to next track, if ignoreerrors set, and loading track raised exception --- jbripper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jbripper.py b/jbripper.py index 93ea0f2..cd8c140 100755 --- a/jbripper.py +++ b/jbripper.py @@ -243,6 +243,7 @@ def run(self): 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():