# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA __title__="KeyJ's iPod shuffle Database Builder" __version__="1.0-rc1" __author__="Martin Fiedler" __email__="martin.fiedler@gmx.net" """ VERSION HISTORY 1.0-rc1 (2006-04-26) * finally(!) added the often-requested auto-rename feature (-r option) 0.7-pre1 (2005-06-09) * 0.6-final was skipped because of the huge load of new (experimental) features in this version :) * rule files allow for nice and flexible fine tuning * fixed --nochdir and --nosmart bugs * numerical sorting (i.e. "track2.mp3" < "track10.mp3") * iTunesSD entries are now copied over from the old file if possible; this should preserve the keys for .aa files 0.6-pre2 (2005-05-02) * fixed file type bug (thanks to Nowhereman) * improved audio book support (thanks to Nowhereman) * files called $foo.book.$type (like example.book.mp3) are stored as audio books now * the subdirectories of /iPod_Control/Music are merged as if they were a single directory 0.6-pre1 (2005-04-23) * always starts from the directory of the executable now * output logging * generating iTunesPState and iTunesStats so that the iPod won't overwrite iTunesShuffle anymore * -> return of smart shuffle ;) * directory display order is identical to playback ordernow * command line options and help * interactive mode, configurable playback volume, directory limitation 0.5 (2005-04-15) * major code refactoring (thanks to Andre Kloss) * removed "smart shuffle" again -- the iPod deleted the file anyway :( * common errors are now reported more concisely * dot files (e.g. ".hidden_file") are now ignored while browsing the iPod 0.4 (2005-03-20) * fixed iPod crashes after playing the shuffle playlist to the end * fixed incorrect databse entries for non-MP3 files 0.3 (2005-03-18) * Python version now includes a "smart shuffle" feature 0.2 (2005-03-15) * added Python version 0.1 (2005-03-13) * initial public release, Win32 only """ import sys,os,os.path,array,getopt,random,fnmatch,string KnownProps=('filename','size','ignore','type','shuffle','reuse','bookmark') Rules=[ ([('filename','~','*.mp3')], {'type':'1', 'shuffle':'1', 'bookmark':'0'}), ([('filename','~','*.m4?')], {'type':'2', 'shuffle':'1', 'bookmark':'0'}), ([('filename','~','*.m4b')], { 'shuffle':'0', 'bookmark':'1'}), ([('filename','~','*.aa')], {'type':'1', 'shuffle':'0', 'bookmark':'1', 'reuse':'1'}), ([('filename','~','*.wav')], {'type':'4', 'shuffle':'0', 'bookmark':'0'}), ([('filename','~','*.book.???')], { 'shuffle':'0', 'bookmark':'1'}), ([('filename','~','*.announce.???')], { 'shuffle':'0', 'bookmark':'0'}), ([('filename','~','/recycled/*')], {'ignore':'1'}), ] class Options: def __init__(self): self.volume = -1 self.interactive = False self.smart = True self.home = True self.logging = True self.reuse = 1 self.logfile = "rebuild_db.log.txt" self.rename = False options = Options() domains=[] total_count=0 KnownEntries={} def open_log(): global logfile if options.logging: try: logfile=file(options.logfile,"w") except IOError: logfile=None else: logfile=None def log(line="",newline=True): global logfile if newline: print line line+="\n" else: print line, line+=" " if logfile: try: logfile.write(line) except IOError: pass def close_log(): global logfile if logfile: logfile.close() def go_home(): if options.home: try: os.chdir(os.path.split(sys.argv[0])[0]) except OSError: pass def filesize(filename): try: return os.stat(filename)[6] except OSError: return 0 def MatchRule(props,rule): try: prop,op,ref=props[rule[0]],rule[1],rule[2] except KeyError: return False if rule[1]=='~': return fnmatch.fnmatchcase(prop.lower(),ref.lower()) elif rule[1]=='=': return cmp(prop,ref)==0 elif rule[1]=='>': return cmp(prop,ref)>0 elif rule[1]=='<': return cmp(prop,ref)<0 else: return False def ParseValue(val): if len(val)>=2 and ((val[0]=="'" and val[-1]=="'") or (val[0]=='"' and val[-1]=='"')): return val[1:-1] # try: # return int(val) # except ValueError: return val def ParseRule(rule): sep_pos=min([rule.find(sep) for sep in "~=<>" if rule.find(sep)>0]) prop=rule[:sep_pos].strip() if not prop in KnownProps: log("WARNING: unknown property `%s'"%prop) return (prop,rule[sep_pos],ParseValue(rule[sep_pos+1:].strip())) def ParseAction(action): #prop,value=[string.strip(x) for x in action.split('=',1)] # XXX l3 = action.split('=',1) prop, value = string.strip(l3[0]), string.strip(l3[1]) if not prop in KnownProps: log("WARNING: unknown property `%s'"%prop) return (prop,ParseValue(value)) def ParseRuleLine(line): line=line.strip() if not(line) or line[0]=="#": return None try: # split line into "ruleset: action" tmp=line.split(":") ruleset=[string.strip(x) for x in ":".join(tmp[:-1]).split(",")] actions=dict([ParseAction(y) for y in tmp[-1].split(",")]) if len(ruleset)==1 and not(ruleset[0]): return ([],actions) else: return ([ParseRule(z) for z in ruleset],actions) except OSError: #(ValueError,IndexError,KeyError): log("WARNING: rule `%s' is malformed, ignoring"%line) return None return None def safe_char(c): if c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_": return c return "_" def rename_safely(path,name): base,ext=os.path.splitext(name) newname=''.join([safe_char(x) for x in base]) if name==newname+ext: return name if os.path.exists("%s/%s%s"%(path,newname,ext)): i=0 while os.path.exists("%s/%s_%d%s"%(path,newname,i,ext)): i+=1 newname+="_%d"%i newname+=ext try: os.rename("%s/%s"%(path,name),"%s/%s"%(path,newname)) except OSError: pass # don't fail if the rename didn't work return newname def write_to_db(filename): global iTunesSD,domains,total_count,KnownEntries,Rules # set default properties props={ 'filename': filename, 'size': str(filesize(filename[1:])), 'ignore': '0', 'type': '1', 'shuffle': '1', 'reuse': str(options.reuse), 'bookmark': '0' } # check and apply rules for ruleset,action in Rules: basis = True for rule in ruleset: basis &= MatchRule(props, rule) # if reduce(operator.__and__,[MatchRule(props,rule) for rule in ruleset],True): if basis: props.update(action) if int(props['ignore']): return 0 # retrieve entry from known entries or rebuild it if int(props['reuse']) and filename in KnownEntries: entry = KnownEntries[filename] else: # entry=int(props['reuse']) and (filename in KnownEntries) and KnownEntries[filename] # if not entry: header[29]=int(props['type']) entry=header.tostring()+ \ "".join([c+"\0" for c in filename[:261]])+ \ "\0"*(525-2*len(filename)) # write entry, modifying shuffleflag and bookmarkflag at least iTunesSD.write(entry[:555]+chr(int(props['shuffle']))+chr(int(props['bookmark']))+entry[557]) if int(props['shuffle']): domains[-1].append(total_count) total_count+=1 return 1 def make_key(s): # XXX return s # if not s: return s # s=s.lower() # for i in xrange(len(s)): # if s[i].isdigit(): break # if not s[i].isdigit(): return s # for j in xrange(i,len(s)): # if not s[j].isdigit(): break # if s[j].isdigit(): j+=1 # return (s[:i],int(s[i:j]),make_key(s[j:])) #def key_repr(x): # if type(x)==types.TupleType: # return "%s%d%s"%(x[0],x[1],key_repr(x[2])) # else: # return x #def cmp_key(a,b): # if type(a)==types.TupleType and type(b)==types.TupleType: # return cmp(a[0],b[0]) or cmp(a[1],b[1]) or cmp_key(a[2],b[2]) # else: # return cmp(key_repr(a),key_repr(b)) class FileEntry: def __init__(self, dir, key, name): self.dir = dir self.key = key self.name = name def file_entry(path,name,prefix=""): if not(name) or name[0]==".": return None fullname="%s/%s"%(path,name) may_rename=not(fullname.startswith("./iPod_Control")) and options.rename try: if os.path.islink(fullname): return None if os.path.isdir(fullname): if may_rename: name=rename_safely(path,name) return FileEntry(True,make_key(name),prefix+name) if os.path.splitext(name)[1].lower() in (".mp3",".m4a",".m4b",".m4p",".aa",".wav"): if may_rename: name=rename_safely(path,name) return FileEntry(False,make_key(name),prefix+name) except OSError: pass return None def browse(path, interactive): global domains if path[-1]=="/": path=path[:-1] displaypath=path[1:] if not displaypath: displaypath="/" if interactive: while 1: try: choice=raw_input("include `%s'? [(Y)es, (N)o, (A)ll] "%displaypath)[:1].lower() except EOFError: raise KeyboardInterrupt if not choice: continue if choice in "at": # all/alle/tous/<dontknow> interactive=False break if choice in "yjos": # yes/ja/oui/si break if choice in "n": # no/nein/non/non? return try: files = [] for name in os.listdir(path): entry = file_entry(path,name) if entry: files.append(entry) # files=filter(None,[file_entry(path,name) for name in os.listdir(path)]) except OSError: return if path=="./iPod_Control/Music": subdirs=[f.name for f in files if f.dir] #files=filter(lambda x: x[0], files) files=[f for f in files if not f.dir] #filter(lambda x: x[0], files) for dir in subdirs: subpath="%s/%s"%(path,dir) try: # files.extend(filter(lambda x: x and x[0],[file_entry(subpath,name,dir+"/") for name in os.listdir(subpath)])) for name in os.listdir(subpath): f = file_entry(subpath,name,dir+"/") if f and not f.dir: files.append(f) except OSError: pass files.sort(cmp=lambda a,b:cmp(a.name, b.name)) #cmp_key) count=len([None for f in files if not f.dir]) if count: domains.append([]) real_count=0 for f in files: fullname="%s/%s"%(path,f.name) if not f.dir: real_count+=write_to_db(fullname[1:]) else: browse(fullname,interactive) if real_count==count: log("%s: %d files"%(displaypath,count)) else: log("%s: %d files (out of %d)"%(displaypath,real_count,count)) def stringval(i): if i<0: i+=0x1000000 return "%c%c%c"%(i&0xFF,(i>>8)&0xFF,(i>>16)&0xFF) def listval(i): if i<0: i+=0x1000000 return [i&0xFF,(i>>8)&0xFF,(i>>16)&0xFF] def make_playback_state(volume=-1): # I'm not at all proud of this function. Why can't stupid Python make strings # mutable?! log("Setting playback state ...",False) PState=[] try: f=file("iPod_Control/iTunes/iTunesPState","rb") a=array.array('B') a.fromstring(f.read()) PState=a.tolist() f.close() except IOError,EOFError: del PState[:] if len(PState)!=21: PState=listval(29)+[0]*15+listval(1) # volume 29, FW ver 1.0 PState[3:15]=[0]*6+[1]+[0]*5 # track 0, shuffle mode, start of track if volume != -1: PState[:3]=listval(volume) try: f=file("iPod_Control/iTunes/iTunesPState","wb") array.array('B',PState).tofile(f) f.close() except IOError: log("FAILED.") return 0 log("OK.") return 1 def make_stats(count): log("Creating statistics file ...",False) try: file("iPod_Control/iTunes/iTunesStats","wb").write(\ stringval(count)+"\0"*3+(stringval(18)+"\xff"*3+"\0"*12)*count) except IOError: log("FAILED.") return 0 log("OK.") return 1 def smart_shuffle(): try: slice_count=max([len(y) for y in domains]) except ValueError: return [] slices=[[] for s in xrange(slice_count)] slice_fill=[0]*slice_count for d in xrange(len(domains)): used=[] if not domains[d]: continue for n in domains[d]: # find slices where the nearest track of the same domain is far away metric=[min([slice_count]+[min(abs(s-u),abs(s-u+slice_count),abs(s-u-slice_count)) for u in used]) for s in xrange(slice_count)] thresh=(max(metric)+1)/2 farthest=[s for s in xrange(slice_count) if metric[s]>=thresh] # find emptiest slices thresh=(min(slice_fill)+max(slice_fill)+1)/2 emptiest=[s for s in xrange(slice_count) if slice_fill[s]<=thresh if (s in farthest)] # choose one of the remaining candidates and add the track to the chosen slice s=random.choice(emptiest or farthest) slices[s].append((n,d)) slice_fill[s]+=1 used.append(s) # shuffle slices and avoid adjacent tracks of the same domain at slice boundaries seq=[] last_domain=-1 for slice in slices: random.shuffle(slice) if len(slice)>2 and slice[0][1]==last_domain: slice.append(slice.pop(0)) seq+=[x[0] for x in slice] last_domain=slice[-1][1] return seq def make_shuffle(count): if options.smart: log("Generating smart shuffle sequence ...",False) seq=smart_shuffle() else: log("Generating shuffle sequence ...",False) seq=range(count) random.shuffle(seq) try: file("iPod_Control/iTunes/iTunesShuffle","wb").write("".join([stringval(x) for x in seq])) except IOError: log("FAILED.") return 0 log("OK.") return 1 def main(dirs): global header,iTunesSD,total_count,KnownEntries,Rules random.seed(1) log("Welcome to %s, version %s"%(__title__,__version__)) log() try: f=file("rebuild_db.rules","r") for x in f.read().split("\n"): p = ParseRuleLine(x) if p: Rules.append(p) # Rules+=filter(None,map(ParseRuleLine,f.read().split("\n"))) f.close() except IOError: pass if not os.path.isdir("iPod_Control/iTunes"): log("""ERROR: No iPod control directory found! Please make sure that: (*) this program's working directory is the iPod's root directory (*) the iPod was correctly initialized with iTunes""") sys.exit(1) header=array.array('B') iTunesSD=None try: iTunesSD=file("iPod_Control/iTunes/iTunesSD","rb") header.fromfile(iTunesSD,51) if options.reuse: iTunesSD.seek(18) entry=iTunesSD.read(558) while len(entry)==558: filename=entry[33::2].split("\0",1)[0] KnownEntries[filename]=entry entry=iTunesSD.read(558) except (IOError,EOFError): pass if iTunesSD: iTunesSD.close() if len(header)==51: log("Using iTunesSD headers from existing database.") if KnownEntries: log("Collected %d entries from existing database."%len(KnownEntries)) else: del header[18:] if len(header)==18: log("Using iTunesSD main header from existing database.") else: del header[:] log("Rebuilding iTunesSD main header from scratch.") header.fromlist([0,0,0,1,6,0,0,0,18]+[0]*9) log("Rebuilding iTunesSD entry header from scratch.") header.fromlist([0,2,46,90,165,1]+[0]*20+[100,0,0,1,0,2,0]) log() try: iTunesSD=file("iPod_Control/iTunes/iTunesSD","wb") header[:18].tofile(iTunesSD) except IOError: log("""ERROR: Cannot write to the iPod database file (iTunesSD)! Please make sure that: (*) you have sufficient permissions to write to the iPod volume (*) you are actually using an iPod shuffle, and not some other iPod model :)""") sys.exit(1) del header[:18] log("Searching for files on your iPod.") try: if dirs: for dir in dirs: browse("./"+dir,options.interactive) else: browse(".",options.interactive) log("%d playable files were found on your iPod."%total_count) log() log("Fixing iTunesSD header.") iTunesSD.seek(0) iTunesSD.write("\0%c%c"%(total_count>>8,total_count&0xFF)) iTunesSD.close() except IOError: log("ERROR: Some strange errors occured while writing iTunesSD.") log(" You may have to re-initialize the iPod using iTunes.") sys.exit(1) if make_playback_state(options.volume)* \ make_stats(total_count)* \ make_shuffle(total_count): log() log("The iPod shuffle database was rebuilt successfully.") log("Have fun listening to your music!") else: log() log("WARNING: The main database file was rebuilt successfully, but there were errors") log(" while resetting the other files. However, playback MAY work correctly.") def help(): print "Usage: %s [OPTION]... [DIRECTORY]..."%sys.argv[0] print """Rebuild iPod shuffle database. Mandatory arguments to long options are mandatory for short options too. -h, --help display this help text -i, --interactive prompt before browsing each directory -v, --volume=VOL set playback volume to a value between 0 and 38 -s, --nosmart do not use smart shuffle -n, --nochdir do not change directory to this scripts directory first -l, --nolog do not create a log file -f, --force always rebuild database entries, do not re-use old ones -L, --logfile set log file name Must be called from the iPod's root directory. By default, the whole iPod is searched for playable files, unless at least one DIRECTORY is specified.""" def opterr(msg): print "parse error:",msg print "use `%s -h' to get help"%sys.argv[0] sys.exit(1) def parse_options(): try: opts,args=getopt.getopt(sys.argv[1:],"hiv:snlfL:r",\ ["help","interactive","volume=","nosmart","nochdir","nolog","force","logfile=","rename"]) except getopt.GetoptError, g: opterr(g.msg) for opt,arg in opts: if opt in ("-h","--help"): help() sys.exit(0) elif opt in ("-i","--interactive"): options.interactive=True elif opt in ("-v","--volume"): try: options.volume=int(arg) except ValueError: opterr("invalid volume") elif opt in ("-s","--nosmart"): options.smart=False elif opt in ("-n","--nochdir"): options.home=False elif opt in ("-l","--nolog"): options.logging=False elif opt in ("-f","--force"): options.reuse=0 elif opt in ("-L","--logfile"): options.logfile=arg elif opt in ("-r","--rename"): options.rename=True return args if __name__=="__main__": args=parse_options() go_home() open_log() try: main(args) except KeyboardInterrupt: log() log("You decided to cancel processing. This is OK, but please note that") log("the iPod database is now corrupt and the iPod won't play!") close_log()