4 # Changes mp3 ID3 tags to match the file names.
6 # I like to store my mp3s in a file structure like this:
8 # Artist Name - Album Title = Conductor [Encoding]/01 - Track 1.mp3
9 # Artist Name - Album Title = Conductor [Encoding]/02 - Track 2.mp3
12 # This script runs through an entire directory of mp3s, and changes all the
13 # ID3 tags to match the file names.
15 # Philosophical aside: I guess you could argue that this defeats the point of
16 # ID3 tags, since under this system, allthe information is stored in the file
17 # name. This is true; however, I need to play my music on a lot of different
18 # systems (like mp3 players) which don't use my file naming scheme.
20 # I have had bad experiences with ID3 tags in the past. Every program seems
21 # to generate and parse them a little bit differently. The ID3 standard
22 # doesn't even specify whether to use unicode vs. Latin-1, let alone what you
23 # should do if a file has conflicting ID3v1 and ID3v2 tags.
25 # It's just easier to use a filing system that actually works well-- the Linux
26 # filesystem -- and regard IDv3 tags as something ephemeral that's generated
27 # out of the "real" file information.
53 # Verifies that there is an executable script named 'target' in the same
54 # directory as this script. If not, prints an error message and exits.
55 def find_companion_script(target):
57 mydir = os.path.dirname(sys.argv[0])
58 target_path = mydir + "/" + target
59 statinfo = os.stat(mydir + "/" + target)
61 if not (mode & stat.S_IEXEC):
62 print "ERROR: " + target + " is not executable"
66 print "ERROR: can't find id3v2_wrapper.sh: " + str(e)
69 # Verifies that a given program is installed.
70 def verify_program_installed(prog):
72 proc = subprocess.Popen(prog, stdout=subprocess.PIPE)
73 line = proc.stdout.readline()
76 print "failed to execute " + str(prog)
79 # Regular expressions for parsing file names--
80 # which is, after all, what this program is all about
81 music_file_re = re.compile(".*\.mp3$")
83 music_file_name_re = re.compile(".*/" +
84 "(?P<dir_name>[^/]*)/" +
85 "(?P<track_number>[0123456789][0123456789]*) - " +
86 "(?P<track_name>[^/]*)" +
87 "\.[a-zA-Z0123456789]*$")
89 audiobook_file_name_re = re.compile(".*/" +
90 "(?P<dir_name>[^/]*)/" +
91 "(?P<track_number>[0123456789][0123456789]*)");
93 dir_name_re = re.compile("(.*/)?" +
94 "(?P<artist>[0-9A-Za-z _.\-]*?) - " +
95 "(?P<album>[0-9A-Za-z _(),'.\-\+]*)" +
96 "(?P<conductor> = [0-9A-Za-z _'.\-]*)?"
97 "(?P<encoding>\[LL\])?$")
99 def self_test_music_file(m, artist, album_name, \
100 conductor, track_number, title):
101 if (m.album.artist != artist):
102 print "FAILED: artist: \"" + m.album.artist + "\""
103 print "\tshould be: \"" + artist + "\""
104 if (m.album.name != album_name):
105 print "FAILED: album_name: \"" + m.album.name + "\""
106 print "\tshould be: \"" + album_name + "\""
107 if (m.album.conductor != conductor):
108 print "FAILED: conductor: \"" + m.album.conductor + "\""
109 print "\tshould be: \"" + conductor + "\""
110 if (m.track_number != track_number):
111 print "FAILED: track_number: \"" + int(m.track_number) + "\""
112 print "\tshould be: \"" + str(track_number) + "\""
113 if (m.title != title):
114 print "FAILED: title: \"" + m.title + "\""
115 print "\tshould be: \"" + title + "\""
118 m = MusicFile.from_filename("./Mozart - " +
119 "Symphony No 26 in Eb Maj - K161a" +
120 " = The Academy of Ancient Music" +
122 self_test_music_file(m,
124 album_name="Symphony No 26 in Eb Maj - K161a",
125 conductor="The Academy of Ancient Music",
130 m = MusicFile.from_filename("./Tchaikovsky - " +
131 "The Sleeping Beauty - Op. 66" +
132 " = Sir Charles Mackerras" +
134 self_test_music_file(m,
135 artist="Tchaikovsky",
136 album_name="The Sleeping Beauty - Op. 66",
137 conductor="Sir Charles Mackerras",
141 # TODO: move John Cage into Comment or secondary author field here.
142 m = MusicFile.from_filename("./Various - " +
143 "American Classics" +
144 "/12 - John Cage - Prelude for Meditation.mp3")
145 self_test_music_file(m,
147 album_name="American Classics",
150 title="John Cage - Prelude for Meditation")
152 # Given a hash H, creates a hash which is the inverse
153 # i.e. if H[k] = v, H'[v] = k
161 except StopIteration:
165 def my_system(ignore_ret, *cmd):
166 if (verbose == True):
168 if (dry_run == False):
170 my_env = {"MALLOC_CHECK_" : "0", "PATH" : os.environ.get("PATH")}
171 retcode = subprocess.call(cmd, env=my_env, shell=False)
173 print "ERROR: Child was terminated by signal", -retcode
175 if ((not ignore_ret) and (retcode != 0)):
176 print "ERROR: Child returned", retcode
178 print "ERROR: Execution failed:", e
181 class FileType(object):
182 def __init__(self, encoding):
183 self.encoding = encoding
186 def __init__(self, artist, name, conductor, encoding):
188 raise MusicFileErr("can't have Album.artist = None")
190 raise MusicFileErr("can't have Album.name = None")
191 self.artist = string.rstrip(artist)
192 self.name = string.rstrip(name)
194 i = conductor.find(' = ')
195 self.conductor = conductor[i+len(' = '):]
198 self.encoding = string.rstrip(encoding) if encoding else ""
200 def from_dirname(dirname):
201 match = dir_name_re.match(dirname)
203 raise MusicFileErr("can't parse directory name \"" +
205 return Album(match.group('artist'), match.group('album'),
206 match.group('conductor'), match.group("encoding"))
207 from_dirname = staticmethod(from_dirname)
210 ret = self.artist + " - " + self.name
211 if (self.conductor != None):
212 ret += " " + self.conductor
213 if (self.encoding != None):
214 ret += " " + self.encoding
217 class MusicFileErr(Exception):
220 class MusicFile(object):
221 id3v2_to_attrib = { 'TIT2' : 'self.title',
222 'TPE1' : 'self.album.artist',
223 'TALB' : 'self.album.name',
224 'TRCK' : 'str(self.track_number)',
225 'TPE3' : 'self.album.conductor',
228 attrib_to_id3v2 = reverse_hash(id3v2_to_attrib)
230 def __init__(self, filename, album, title, track_number):
231 self.filename = filename
234 self.track_number = int(track_number)
236 def from_filename(filename):
238 match = audiobook_file_name_re.match(filename)
241 match = music_file_name_re.match(filename)
242 track_name = match.group('track_name')
244 raise MusicFileErr("can't parse music file name \"" +
246 album = Album.from_dirname(match.group('dir_name'))
247 return MusicFile(filename, album,
249 match.group('track_number'))
250 from_filename = staticmethod(from_filename)
253 ret = self.album.to_s() + "/" + \
254 ("%02d" % self.track_number) + " - " + self.title
257 def clear_tags(self):
258 my_system(True, id3v2_wrapper, "--delete-v1", self.filename)
259 my_system(True, id3v2_wrapper, "--delete-v2", self.filename)
261 def add_tag(self, att, expr):
262 attribute = "--" + att
263 my_system(False, "id3v2", attribute, expr, self.filename)
266 i = self.id3v2_to_attrib.iteritems()
270 self.add_tag(att, eval(expr))
271 except StopIteration:
275 ## Make sure that id3v2 is installed
276 if not verify_program_installed(["id3v2", "--version"]):
277 print "You must install the id3v2 program to run this script."
280 ## Find id3v2_wrapper.sh
281 id3v2_wrapper = find_companion_script('id3v2_wrapper.sh')
285 print os.path.basename(sys.argv[0]) + ": the mp3 tagging program"
287 print "Usage: " + os.path.basename(sys.argv[0]) + \
288 " [-h][-d][-s] [dirs]"
289 print "-h: this help message"
290 print "-d: dry-run mode"
291 print "-s: self-test"
292 print "-A: audiobook mode"
293 print "dirs: directories to search for albums."
294 print "This program skips dirs with \"[LL]\" in the name."
298 optlist, dirs = getopt.getopt(sys.argv[1:], ':dhi:svA')
299 except getopt.GetoptError:
319 if (re.search("\[LL\]", dir)):
320 print "skipping \"" + dir + "\"..."
322 # Assume that paths without a directory prefix are local
323 if ((dir[0] != "/") and (dir.find("./") != 0)):
326 # Validate that 'dir' is a directory and we can access the entries
327 # Note: this does not protect against having nested directories with
330 entries = os.listdir(dir)
332 print "ERROR: cannot stat entries of \"" + dir + "\""
335 # Process all files in the directory
337 print "******** find -L " + dir + " -noleaf"
338 proc = subprocess.Popen(['find', '-L', dir, '-noleaf'],\
339 stdout=subprocess.PIPE)
340 line = proc.stdout.readline()
342 file_name = line.strip()
343 if (music_file_re.match(file_name)):
345 m = MusicFile.from_filename(file_name)
349 print "SUCCESS: " + file_name
350 total_albums = total_albums + 1
351 except MusicFileErr, e:
352 print "ERROR: " + str(e)
353 line = proc.stdout.readline()
359 print "Successfully processed " + str(total_albums) + " total mp3s"