Makefile: add pickrand
[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 audiobook = False
48
49 # globals
50 total_albums = 0
51 id3v2_wrapper = ""
52
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):
56     try:
57         mydir = os.path.dirname(sys.argv[0])
58         target_path = mydir + "/" + target
59         statinfo = os.stat(mydir + "/" + target)
60         mode = statinfo[0]
61         if not (mode & stat.S_IEXEC):
62             print "ERROR: " + target + " is not executable"
63             sys.exit(1)
64         return target_path 
65     except Exception, e:
66         print "ERROR: can't find id3v2_wrapper.sh: " + str(e)
67         sys.exit(1)
68
69 # Verifies that a given program is installed.
70 def verify_program_installed(prog):
71     try:
72         proc = subprocess.Popen(prog, stdout=subprocess.PIPE)
73         line = proc.stdout.readline()
74         return True
75     except Exception, e:
76         print "failed to execute " + str(prog)
77         return False
78
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$")
82
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]*$")
88
89 audiobook_file_name_re = re.compile(".*/" +
90             "(?P<dir_name>[^/]*)/" +
91             "(?P<track_number>[0123456789][0123456789]*)");
92
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\])?$")
98
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 + "\""
116
117 def run_self_test():
118     m = MusicFile.from_filename("./Mozart - " +
119                 "Symphony No 26 in Eb Maj - K161a" + 
120                 " = The Academy of Ancient Music" +
121                 "/01 - Adagio.mp3")
122     self_test_music_file(m,
123                     artist="Mozart",
124                     album_name="Symphony No 26 in Eb Maj - K161a",
125                     conductor="The Academy of Ancient Music",
126                     track_number=1,
127                     title="Adagio")
128
129
130     m = MusicFile.from_filename("./Tchaikovsky - " +
131                 "The Sleeping Beauty - Op. 66" + 
132                 " = Sir Charles Mackerras" +
133                 "/02 - Scene.mp3")
134     self_test_music_file(m,
135                     artist="Tchaikovsky",
136                     album_name="The Sleeping Beauty - Op. 66",
137                     conductor="Sir Charles Mackerras",
138                     track_number=2,
139                     title="Scene")
140
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, 
146                     artist="Various",
147                     album_name="American Classics",
148                     conductor="",
149                     track_number=12,
150                     title="John Cage - Prelude for Meditation")
151
152 # Given a hash H, creates a hash which is the inverse
153 # i.e. if H[k] = v, H'[v] = k
154 def reverse_hash(h):
155     ret = dict()
156     i = h.iteritems()
157     while 1:
158         try:
159             k,v = i.next()
160             ret[v] = k
161         except StopIteration:
162             break
163     return ret
164
165 def my_system(ignore_ret, *cmd):
166     if (verbose == True):
167         print cmd
168     if (dry_run == False):
169         try:
170             my_env = {"MALLOC_CHECK_" : "0", "PATH" : os.environ.get("PATH")}
171             retcode = subprocess.call(cmd, env=my_env, shell=False)
172             if (retcode < 0):
173                 print "ERROR: Child was terminated by signal", -retcode
174             else:
175                 if ((not ignore_ret) and (retcode != 0)):
176                     print "ERROR: Child returned", retcode
177         except OSError, e:
178             print "ERROR: Execution failed:", e
179
180 # CLASSES
181 class FileType(object):
182     def __init__(self, encoding):
183         self.encoding = encoding
184
185 class Album(object):
186     def __init__(self, artist, name, conductor, encoding):
187         if (artist == None):
188             raise MusicFileErr("can't have Album.artist = None")
189         if (name == None):
190             raise MusicFileErr("can't have Album.name = None")
191         self.artist = string.rstrip(artist)
192         self.name = string.rstrip(name)
193         if (conductor):
194             i = conductor.find(' = ')
195             self.conductor = conductor[i+len(' = '):]
196         else:
197             self.conductor = ""
198         self.encoding = string.rstrip(encoding) if encoding else ""
199
200     def from_dirname(dirname):
201         match = dir_name_re.match(dirname)
202         if (not match):
203             raise MusicFileErr("can't parse directory name \"" + 
204                                 dirname + "\"")
205         return Album(match.group('artist'), match.group('album'), 
206                      match.group('conductor'), match.group("encoding"))
207     from_dirname = staticmethod(from_dirname)
208
209     def to_s(self):
210         ret = self.artist + " - " + self.name
211         if (self.conductor != None):
212             ret += " " + self.conductor
213         if (self.encoding != None):
214             ret += " " + self.encoding
215         return ret
216
217 class MusicFileErr(Exception):
218     pass
219
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',
226                         #'TYER' : 'year'
227                     }
228     attrib_to_id3v2 = reverse_hash(id3v2_to_attrib)
229
230     def __init__(self, filename, album, title, track_number):
231         self.filename = filename
232         self.album = album
233         self.title = title
234         self.track_number = int(track_number)
235
236     def from_filename(filename):
237         if (audiobook):
238             match = audiobook_file_name_re.match(filename)
239             track_name = ""
240         else:
241             match = music_file_name_re.match(filename)
242             track_name = match.group('track_name')
243         if (not match):
244             raise MusicFileErr("can't parse music file name \"" + 
245                             filename + "\"")
246         album = Album.from_dirname(match.group('dir_name'))
247         return MusicFile(filename, album, 
248                         track_name,
249                         match.group('track_number'))
250     from_filename = staticmethod(from_filename)
251
252     def to_s(self):
253         ret = self.album.to_s() + "/" + \
254                 ("%02d" % self.track_number) + " - " + self.title
255         return ret
256
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)
260
261     def add_tag(self, att, expr):
262         attribute = "--" + att
263         my_system(False, "id3v2", attribute, expr, self.filename)
264
265     def set_tags(self):
266         i = self.id3v2_to_attrib.iteritems()
267         while 1:
268             try:
269                 att,expr = i.next()
270                 self.add_tag(att, eval(expr))
271             except StopIteration:
272                 break
273 # CODE
274
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."
278     sys.exit(1)
279
280 ## Find id3v2_wrapper.sh
281 id3v2_wrapper = find_companion_script('id3v2_wrapper.sh')
282
283 ## Parse options
284 def Usage():
285     print os.path.basename(sys.argv[0]) + ": the mp3 tagging program"
286     print
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."
295     sys.exit(1)
296
297 try:
298     optlist, dirs = getopt.getopt(sys.argv[1:], ':dhi:svA')
299 except getopt.GetoptError:
300     Usage()
301
302 for opt in optlist:
303     if opt[0] == '-h':
304         Usage()
305     if opt[0] == '-d':
306         dry_run = True
307     if opt[0] == '-v':
308         verbose = True
309     if opt[0] == '-s':
310         self_test = True
311     if opt[0] == '-A':
312         audiobook = True
313
314 if (self_test):
315     run_self_test()
316     sys.exit(0)
317
318 for dir in dirs:
319     if (re.search("\[LL\]", dir)):
320         print "skipping \"" + dir + "\"..."
321         continue
322     # Assume that paths without a directory prefix are local
323     if ((dir[0] != "/") and (dir.find("./") != 0)):
324         dir = "./" + dir
325
326     # Validate that 'dir' is a directory and we can access the entries
327     # Note: this does not protect against having nested directories with
328     # bad permissions
329     try:
330         entries = os.listdir(dir)
331     except:
332         print "ERROR: cannot stat entries of \"" + dir + "\""
333         continue
334
335     # Process all files in the directory
336     if (verbose):
337         print "******** find -L " + dir + " -noleaf"
338     proc = subprocess.Popen(['find', '-L', dir, '-noleaf'],\
339             stdout=subprocess.PIPE)
340     line = proc.stdout.readline()
341     while line != '':
342         file_name = line.strip()
343         if (music_file_re.match(file_name)):
344             try:
345                 m = MusicFile.from_filename(file_name)
346                 m.clear_tags()
347                 m.set_tags()
348                 if (verbose):
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()
354     if (verbose):
355         print "********"
356
357 if (dry_run):
358     print "(dry run)",
359 print "Successfully processed " + str(total_albums) + " total mp3s"