Add tagger.py
[cmccabe-bin] / tagger.py
1 #!/usr/bin/python
2
3 #
4 # Changes mp3 ID3 tags to match the file names.
5 #
6 # I like to store my mp3s in a file structure like this:
7 #
8 # Artist Name - Album Title = Conductor [Encoding]/01 - Track 1.mp3
9 # Artist Name - Album Title = Conductor [Encoding]/02 - Track 2.mp3
10 # ...
11 #
12 # This script runs through an entire directory of mp3s, and changes all the
13 # ID3 tags to match the file names.
14 #
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.
19 #
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.
24 #
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.
28 #
29 # Colin McCabe
30 # 2008/12/7
31 #
32
33 import getopt
34 import os
35 import re
36 import stat
37 import string
38 import subprocess
39 import sys
40
41 # GLOBALS
42
43 # script arguments
44 dry_run = False
45 verbose = False
46 self_test = False
47
48 # globals
49 total_albums = 0
50 id3v2_wrapper = ""
51
52 # Verifies that there is an executable script named 'target' in the same 
53 # directory as this script. If not, prints an error message and exits.
54 def find_companion_script(target):
55     try:
56         mydir = os.path.dirname(sys.argv[0])
57         target_path = mydir + "/" + target
58         statinfo = os.stat(mydir + "/" + target)
59         mode = statinfo[0]
60         if not (mode & stat.S_IEXEC):
61             print "ERROR: " + target + " is not executable"
62             sys.exit(1)
63         return target_path 
64     except Exception, e:
65         print "ERROR: can't find id3v2_wrapper.sh: " + str(e)
66         sys.exit(1)
67
68 # Regular expressions for parsing file names--
69 # which is, after all, what this program is all about
70 music_file_re = re.compile(".*\.mp3$")
71
72 music_file_name_re = re.compile(".*/" +
73             "(?P<dir_name>[^/]*)/" +
74             "(?P<track_number>[0123456789][0123456789]) - " +
75             "(?P<track_name>[^/]*)" +
76             "\.[a-zA-Z0123456789]*$")
77
78 dir_name_re = re.compile("(.*/)?" +
79             "(?P<artist>[0-9A-Za-z _.\-]*?) - " +
80             "(?P<album>[0-9A-Za-z _(),'.\-\+]*)" + 
81             "(?P<conductor> = [0-9A-Za-z _'.\-]*)?"
82             "(?P<encoding>\[LL\])?$")
83
84 def self_test_music_file(m, artist, album_name, \
85                         conductor, track_number, title):
86     if (m.album.artist != artist):
87         print "FAILED: artist: \"" + m.album.artist + "\""
88         print "\tshould be: \"" + artist + "\""
89     if (m.album.name != album_name):
90         print "FAILED: album_name: \"" + m.album.name + "\""
91         print "\tshould be: \"" + album_name + "\""
92     if (m.album.conductor != conductor):
93         print "FAILED: conductor: \"" + m.album.conductor + "\""
94         print "\tshould be: \"" + conductor + "\""
95     if (m.track_number != track_number):
96         print "FAILED: track_number: \"" + int(m.track_number) + "\""
97         print "\tshould be: \"" + str(track_number) + "\""
98     if (m.title != title):
99         print "FAILED: title: \"" + m.title + "\""
100         print "\tshould be: \"" + title + "\""
101
102 def run_self_test():
103     m = MusicFile.from_filename("./Mozart - " +
104                 "Symphony No 26 in Eb Maj - K161a" + 
105                 " = The Academy of Ancient Music" +
106                 "/01 - Adagio.mp3")
107     self_test_music_file(m,
108                     artist="Mozart",
109                     album_name="Symphony No 26 in Eb Maj - K161a",
110                     conductor="The Academy of Ancient Music",
111                     track_number=1,
112                     title="Adagio")
113
114
115     m = MusicFile.from_filename("./Tchaikovsky - " +
116                 "The Sleeping Beauty - Op. 66" + 
117                 " = Sir Charles Mackerras" +
118                 "/02 - Scene.mp3")
119     self_test_music_file(m,
120                     artist="Tchaikovsky",
121                     album_name="The Sleeping Beauty - Op. 66",
122                     conductor="Sir Charles Mackerras",
123                     track_number=2,
124                     title="Scene")
125
126     # TODO: move John Cage into Comment or secondary author field here.
127     m = MusicFile.from_filename("./Various - " +
128                 "American Classics" +
129                 "/12 - John Cage - Prelude for Meditation.mp3")
130     self_test_music_file(m, 
131                     artist="Various",
132                     album_name="American Classics",
133                     conductor="",
134                     track_number=12,
135                     title="John Cage - Prelude for Meditation")
136
137 # Given a hash H, creates a hash which is the inverse
138 # i.e. if H[k] = v, H'[v] = k
139 def reverse_hash(h):
140     ret = dict()
141     i = h.iteritems()
142     while 1:
143         try:
144             k,v = i.next()
145             ret[v] = k
146         except StopIteration:
147             break
148     return ret
149
150 def my_system(ignore_ret, *cmd):
151     if (verbose == True):
152         print cmd
153     if (dry_run == False):
154         try:
155             my_env = {"MALLOC_CHECK_" : "0", "PATH" : os.environ.get("PATH")}
156             retcode = subprocess.call(cmd, env=my_env, shell=False)
157             if (retcode < 0):
158                 print "ERROR: Child was terminated by signal", -retcode
159             else:
160                 if ((not ignore_ret) and (retcode != 0)):
161                     print "ERROR: Child returned", retcode
162         except OSError, e:
163             print "ERROR: Execution failed:", e
164
165 # CLASSES
166 class FileType(object):
167     def __init__(self, encoding):
168         self.encoding = encoding
169
170 class Album(object):
171     def __init__(self, artist, name, conductor, encoding):
172         if (artist == None):
173             raise MusicFileErr("can't have Album.artist = None")
174         if (name == None):
175             raise MusicFileErr("can't have Album.name = None")
176         self.artist = string.rstrip(artist)
177         self.name = string.rstrip(name)
178         if (conductor):
179             i = conductor.find(' = ')
180             self.conductor = conductor[i+len(' = '):]
181         else:
182             self.conductor = ""
183         self.encoding = string.rstrip(encoding) if encoding else ""
184
185     def from_dirname(dirname):
186         match = dir_name_re.match(dirname)
187         if (not match):
188             raise MusicFileErr("can't parse directory name \"" + 
189                                 dirname + "\"")
190         return Album(match.group('artist'), match.group('album'), 
191                      match.group('conductor'), match.group("encoding"))
192     from_dirname = staticmethod(from_dirname)
193
194     def to_s(self):
195         ret = self.artist + " - " + self.name
196         if (self.conductor != None):
197             ret += " " + self.conductor
198         if (self.encoding != None):
199             ret += " " + self.encoding
200         return ret
201
202 class MusicFileErr(Exception):
203     pass
204
205 class MusicFile(object):
206     id3v2_to_attrib = { 'TIT2' : 'self.title',
207                         'TPE1' : 'self.album.artist',
208                         'TALB' : 'self.album.name',
209                         'TRCK' : 'str(self.track_number)',
210                         'TPE3' : 'self.album.conductor',
211                         #'TYER' : 'year'
212                     }
213     attrib_to_id3v2 = reverse_hash(id3v2_to_attrib)
214
215     def __init__(self, filename, album, title, track_number):
216         self.filename = filename
217         self.album = album
218         self.title = title
219         self.track_number = int(track_number)
220
221     def from_filename(filename):
222         match = music_file_name_re.match(filename)
223         if (not match):
224             raise MusicFileErr("can't parse music file name \"" + 
225                             filename + "\"")
226         album = Album.from_dirname(match.group('dir_name'))
227         return MusicFile(filename, album, 
228                         match.group('track_name'),
229                         match.group('track_number'))
230     from_filename = staticmethod(from_filename)
231
232     def to_s(self):
233         ret = self.album.to_s() + "/" + \
234                 ("%02d" % self.track_number) + " - " + self.title
235         return ret
236
237     def clear_tags(self):
238         my_system(True, id3v2_wrapper, "--delete-v1", self.filename)
239         my_system(True, id3v2_wrapper, "--delete-v2", self.filename)
240
241     def add_tag(self, att, expr):
242         attribute = "--" + att
243         my_system(False, "id3v2", attribute, expr, self.filename)
244
245     def set_tags(self):
246         i = self.id3v2_to_attrib.iteritems()
247         while 1:
248             try:
249                 att,expr = i.next()
250                 self.add_tag(att, eval(expr))
251             except StopIteration:
252                 break
253 # CODE
254
255 ## Find id3v2_wrapper.sh
256 id3v2_wrapper = find_companion_script('id3v2_wrapper.sh')
257
258 ## Parse options
259 def Usage():
260     print os.path.basename(sys.argv[0]) + ": the mp3 tagging program"
261     print
262     print "Usage: " + os.path.basename(sys.argv[0]) + \
263             " [-h][-d][-s] [dirs]"
264     print "-h: this help message"
265     print "-d: dry-run mode"
266     print "-s: self-test"
267     print "dirs: directories to search for albums."
268     print "This program skips dirs with \"[LL]\" in the name."
269     sys.exit(1)
270
271 try:
272     optlist, dirs = getopt.getopt(sys.argv[1:], ':dhi:sv')
273 except getopt.GetoptError:
274     Usage()
275
276 for opt in optlist:
277     if opt[0] == '-h':
278         Usage()
279     if opt[0] == '-d':
280         dry_run = True
281     if opt[0] == '-v':
282         verbose = True
283     if opt[0] == '-s':
284         self_test = True
285
286 if (self_test):
287     run_self_test()
288     sys.exit(0)
289
290 for dir in dirs:
291     if (re.search("\[LL\]", dir)):
292         print "skipping \"" + dir + "\"..."
293         continue
294     # Assume that paths without a directory prefix are local
295     if ((dir[0] != "/") and (dir.find("./") != 0)):
296         dir = "./" + dir
297
298     # Validate that 'dir' is a directory and we can access the entries
299     # Note: this does not protect against having nested directories with
300     # bad permissions
301     try:
302         entries = os.listdir(dir)
303     except:
304         print "ERROR: cannot stat entries of \"" + dir + "\""
305         continue
306
307     # Process all files in the directory
308     if (verbose):
309         print "******** find -L " + dir + " -noleaf"
310     proc = subprocess.Popen(['find', '-L', dir, '-noleaf'],\
311             stdout=subprocess.PIPE)
312     line = proc.stdout.readline()
313     while line != '':
314         file_name = line.strip()
315         if (music_file_re.match(file_name)):
316             try:
317                 m = MusicFile.from_filename(file_name)
318                 m.clear_tags()
319                 m.set_tags()
320                 if (verbose):
321                     print "SUCCESS: " + file_name
322                 total_albums = total_albums + 1
323             except MusicFileErr, e:
324                 print "ERROR: " + str(e)
325         line = proc.stdout.readline()
326     if (verbose):
327         print "********"
328
329 if (dry_run):
330     print "(dry run)",
331 print "Successfully processed " + str(total_albums) + " total mp3s"