aboutsummaryrefslogtreecommitdiff
path: root/rebuild_db.py
diff options
context:
space:
mode:
Diffstat (limited to 'rebuild_db.py')
-rw-r--r--rebuild_db.py691
1 files changed, 691 insertions, 0 deletions
diff --git a/rebuild_db.py b/rebuild_db.py
new file mode 100644
index 0000000..ee42792
--- /dev/null
+++ b/rebuild_db.py
@@ -0,0 +1,691 @@
+#!/usr/bin/env python
+# -*- coding: iso-8859-1
+
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# 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 patched for modern Python 3"
+__version__="1.0-rc2"
+__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,types,fnmatch,operator,string
+from functools import reduce
+
+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}),
+]
+
+Options={
+ "volume":None,
+ "interactive":False,
+ "smart":True,
+ "home":True,
+ "logging":True,
+ "reuse":1,
+ "logfile":"rebuild_db.log.txt",
+ "rename":False
+}
+domains=[]
+total_count=0
+KnownEntries={}
+
+
+################################################################################
+
+
+def open_log():
+ global logfile
+ if Options['logging']:
+ try:
+ logfile=open(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, end=' ')
+ 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 None
+
+
+################################################################################
+
+
+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=list(map(string.strip,action.split('=',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=list(map(string.strip,":".join(tmp[:-1]).split(",")))
+ actions=dict(list(map(ParseAction,tmp[-1].split(","))))
+ if len(ruleset)==1 and not(ruleset[0]):
+ return ([],actions)
+ else:
+ return (list(map(ParseRule,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(map(safe_char,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': filesize(filename[1:]),
+ 'ignore': 0,
+ 'type': 1,
+ 'shuffle': 1,
+ 'reuse': Options['reuse'],
+ 'bookmark': 0
+ }
+
+ # check and apply rules
+ for ruleset,action in Rules:
+ if reduce(operator.__and__,[MatchRule(props,rule) for rule in ruleset],True):
+ props.update(action)
+ if props['ignore']: return 0
+
+ # retrieve entry from known entries or rebuild it
+ entry=props['reuse'] and (filename in KnownEntries) and KnownEntries[filename]
+ if not entry:
+ header[29]=props['type']
+ # debug galt:
+ #galt = entry=header.tostring()
+ #print("typeofGALT",type(galt))
+ # this is apparently returning type bytes ?
+ galt = "".join([c+"\0" for c in filename[:261]])+ \
+ "\0"*(525-2*len(filename))
+ entry=header.tostring()+galt.encode()
+
+# ORIG
+# entry=header.tostring()+ \
+# "".join([c+"\0" for c in filename[:261]])+ \
+# "\0"*(525-2*len(filename))
+
+ # write entry, modifying shuffleflag and bookmarkflag at least
+
+ #galt = entry[557]
+ #print("typeofGALT",type(galt)," =",galt)
+
+ iTunesSD.write(entry[:555]+(chr(props['shuffle'])+chr(props['bookmark'])+chr(entry[557])).encode())
+#ORIG iTunesSD.write(entry[:555]+chr(props['shuffle'])+chr(props['bookmark'])+entry[557])
+ if props['shuffle']: domains[-1].append(total_count)
+ total_count+=1
+ return 1
+
+
+def make_key(s):
+ if not s: return s
+ s=s.lower()
+ for i in range(len(s)):
+ if s[i].isdigit(): break
+ if not s[i].isdigit(): return s
+ for j in range(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)==tuple:
+ return "%s%d%s"%(x[0],x[1],key_repr(x[2]))
+ else:
+ return x
+
+#galt's attempt to substitute the cmp function removed
+def cmp(xx,yy):
+ x = key_repr(xx)
+ y = key_repr(yy)
+ if (x > y):
+ return 1
+ if (x < y):
+ return -1
+ return 0
+
+def cmp_key(a,b):
+ if type(a)==tuple and type(b)==tuple:
+ 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))
+
+
+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 (0,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 (1,make_key(name),prefix+name)
+ except OSError:
+ pass
+ return None
+
+
+# added by galt since it was not yet in python3.1.2
+def cmp_to_key(mycmp):
+ 'Convert a cmp= function into a key= function'
+ class K(object):
+ def __init__(self, obj, *args):
+ self.obj = obj
+ def __lt__(self, other):
+ return mycmp(self.obj, other.obj) < 0
+ def __gt__(self, other):
+ return mycmp(self.obj, other.obj) > 0
+ def __eq__(self, other):
+ return mycmp(self.obj, other.obj) == 0
+ def __le__(self, other):
+ return mycmp(self.obj, other.obj) <= 0
+ def __ge__(self, other):
+ return mycmp(self.obj, other.obj) >= 0
+ def __ne__(self, other):
+ return mycmp(self.obj, other.obj) != 0
+ return K
+
+
+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=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=0
+ break
+ if choice in "yjos": # yes/ja/oui/si
+ break
+ if choice in "n": # no/nein/non/non?
+ return 0
+
+ try:
+ files=[_f for _f in [file_entry(path,name) for name in os.listdir(path)] if _f]
+ except OSError:
+ return
+
+ if path=="./iPod_Control/Music":
+ subdirs=[x[2] for x in files if not x[0]]
+ files=[x for x in files if x[0]]
+ for dir in subdirs:
+ subpath="%s/%s"%(path,dir)
+ try:
+ files.extend([x for x in [file_entry(subpath,name,dir+"/") for name in os.listdir(subpath)] if x and x[0]])
+ except OSError:
+ pass
+
+#orig files.sort(cmp_key)
+ files.sort(key=cmp_to_key(cmp_key))
+ count=len([None for x in files if x[0]])
+ if count: domains.append([])
+
+ real_count=0
+ for item in files:
+ fullname="%s/%s"%(path,item[2])
+ if item[0]:
+ 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=None):
+ # 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=open("iPod_Control/iTunes/iTunesPState","rb")
+ a=array.array('B')
+ a.frombytes(f.read())
+ PState=a.tolist()
+ f.close()
+ except IOError as 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 is not None:
+ PState[:3]=listval(volume)
+ try:
+ f=open("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:
+ open("iPod_Control/iTunes/iTunesStats","wb").write(\
+ (stringval(count)+"\0"*3+(stringval(18)+"\xff"*3+"\0"*12)*count).encode() )
+ except IOError:
+ log("FAILED.")
+ return 0
+ log("OK.")
+ return 1
+
+
+################################################################################
+
+
+def smart_shuffle():
+ try:
+ slice_count=max(list(map(len,domains)))
+ except ValueError:
+ return []
+ slices=[[] for x in range(slice_count)]
+ slice_fill=[0]*slice_count
+
+ for d in range(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 range(slice_count)]
+ thresh=(max(metric)+1)/2
+ farthest=[s for s in range(slice_count) if metric[s]>=thresh]
+
+ # find emptiest slices
+ thresh=(min(slice_fill)+max(slice_fill)+1)/2
+ emptiest=[s for s in range(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):
+ random.seed()
+ if Options['smart']:
+ log("Generating smart shuffle sequence ...",False)
+ seq=smart_shuffle()
+ else:
+ log("Generating shuffle sequence ...",False)
+ seq=list(range(count))
+ random.shuffle(seq)
+ try:
+ open("iPod_Control/iTunes/iTunesShuffle","wb").write(("".join(map(stringval,seq))).encode())
+ except IOError:
+ log("FAILED.")
+ return 0
+ log("OK.")
+ return 1
+
+
+################################################################################
+
+
+def main(dirs):
+ global header,iTunesSD,total_count,KnownEntries,Rules
+ log("Welcome to %s, version %s"%(__title__,__version__))
+ log()
+
+ try:
+ f=open("rebuild_db.rules","r")
+ Rules+=[_f for _f in list(map(ParseRuleLine,f.read().split("\n"))) if _f]
+ 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=open("iPod_Control/iTunes/iTunesSD","rb")
+ header.fromfile(iTunesSD,51)
+ if Options['reuse']:
+ iTunesSD.seek(18)
+ entry=iTunesSD.read(558)
+ while len(entry)==558:
+ filename=str(entry[33::2]).split("\0",1)[0]
+#orig 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=open("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)).encode())
+ 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 as message:
+ opterr(message)
+ 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()