]> diplodocus.org Git - flac-archive/blob - fa-flacd
hack out my ancient shitty util library
[flac-archive] / fa-flacd
1 #! /usr/bin/python
2
3 """
4 =head1 NAME
5
6 B<fa-flacd> - archive CDs to single FLAC files
7
8 =head1 SYNOPSIS
9
10 B<fa-flacd> [B<-j> I<jobs>] [B<-v>]
11
12 =head1 DESCRIPTION
13
14 B<fa-flacd> and B<fa-rip> together comprise B<flac-archive>, a system
15 for archiving audio CDs to single FLAC files. B<fa-flacd> is the guts
16 of the system. It runs in the directory where the audio archives are
17 stored, scanning for new ripped CDs to encode and rename; it never
18 exits. B<fa-rip> generates the inputs for B<fa-flacd>: the ripped WAV
19 file, Vorbis tags, and a cuesheet.
20
21 Both programs expect to be run from the same directory. They use that
22 directory to manage directories named by artist. Intermediate files
23 are written to temporary directories here. B<fa-flacd> processes the
24 temporary directories into per-album files in the artist directories.
25
26 Every 5 seconds, B<fa-flacd> scans its current directory for
27 directories with a file called "tags" and creates a processing job for
28 each one. The number of jobs B<fa-flacd> attempts to run is
29 controlled by the B<-j> option and defaults to 4. B<fa-flacd> will
30 print diagnostic output when the B<-v> option is given.
31
32 A processing job first renames the directory's "tags" file to
33 "using-tags" so that B<ra-flacd> will not try to start another job for
34 this directory. This file is left as is when an error is encountered,
35 so a new job will not be started until the user corrects the error
36 condition and renames "using-tags" back to "tags". Next, it encodes
37 the "wav" file to a FLAC file, using the "cue" file for the cuesheet
38 and "using-tags" for Vorbis tags. Any diagnostic output is saved in
39 the "log" file. Finally, B<fa-flacd> moves the "cue" and "log" files
40 to the artist directory (named by album) and removes the temporary
41 directory.
42
43 If the temporary directory contains an executable file named
44 "post-processor", B<fa-flacd> executes that file with the relative
45 path to the output FLAC file as an argument. The output files are in
46 their final location when "post-processor" starts. Possible uses are
47 running B<flac2mp3>, moving the output files to a different location,
48 removing the lock file, or adding to a database. The standard input,
49 output, and error streams are inherited from B<fa-flacd>, so they may
50 be connected to anything from a tty to /dev/null. This means that you
51 may want to redirect these streams, if you want to save them or do any
52 logging.
53
54 =head1 OPTIONS
55
56 =over 4
57
58 =item B<-j> [B<--jobs>] I<jobs>
59
60 Run up to I<jobs> jobs instead of the default 4.
61
62 =item B<-v> [B<--verbose>]
63
64 Print diagnostic information.
65
66 =back
67
68 =head1 AUTHORS
69
70 Written by Eric Gillespie <epg@pretzelnet.org>.
71
72 flac-archive is free software; you may redistribute it and/or modify
73 it under the same terms as Perl itself.
74
75 =cut
76
77 """
78
79 import os
80 import re
81 import sys
82 import time
83 import traceback
84 from errno import EEXIST, ENOENT
85 from glob import glob
86 from optparse import OptionParser
87
88 from flac_archive.tags import Tags
89
90 def c(f, *args):
91 try:
92 return f(*args)
93 except EnvironmentError, error:
94 error.msg = '%s.%s(%s): ' % (f.__module__, f.__name__,
95 ', '.join(map(str, args)))
96 raise
97
98 def die(e, *args):
99 sys.stderr.write(''.join(args))
100 sys.exit(e)
101
102 def spew(*args):
103 if verbose:
104 for i in args:
105 sys.stderr.write(i)
106
107 ################################################################################
108 # The child processes
109
110 def run_flac(infile, cue, outfile, tags):
111 argv = ['flac', '-o', outfile + '.flac-tmp',
112 '--delete-input-file', '-V', '--no-padding', '--best']
113 if cue != None:
114 argv.extend(['--cuesheet', cue])
115 for i in tags:
116 argv.extend(['-T', i])
117 argv.append(infile)
118 # flac 1.1.3 PICTURE support
119 if os.path.exists('cover.front'):
120 argv.extend(['--picture', '3|image/jpeg|||cover.front'])
121
122 spew('Running flac\n')
123 status = os.spawnvp(os.P_WAIT, argv[0], argv)
124 if status > 0:
125 die(2, 'flac exited with status ', str(status))
126 elif status < 0:
127 die(2, 'flac killed with signal ', str(abs(status)))
128
129 c(os.rename, outfile + '.flac-tmp', outfile + '.flac')
130
131 def flac(dir, tracknum, tags):
132 """Encode a single wav file to a single flac file, whether the wav and
133 flac files represent individual tracks or whole discs."""
134
135 separator = ' '
136 if len(tags.get('ALBUMARTIST')) > 0:
137 artist_tag = tags.gets('ALBUMARTIST', separator=', ')
138 else:
139 artist_tag = tags.gets('ARTIST', separator=', ')
140 artist = (artist_tag or '').replace('/', '_')
141 album = (tags.gets('ALBUM', separator=separator) or '').replace('/', '_')
142 discnum = tags.gets('DISCNUMBER')
143
144 spew('mkdir(%s)\n' % (artist,))
145 try:
146 c(os.mkdir, artist)
147 except EnvironmentError, error:
148 error.errno == EEXIST or die(2, error.msg, traceback.format_exc())
149
150 if tracknum != None:
151 outdir = '/'.join([artist, album])
152 spew('mkdir(%s)\n' % (outdir,))
153 try:
154 c(os.mkdir, outdir)
155 except EnvironmentError, error:
156 error.errno == EEXIST or die(2, error.msg, traceback.format_exc())
157
158 spew('chdir(%s)\n' % (dir,))
159 c(os.chdir, dir)
160
161 if tracknum == None:
162 outfile = album
163 if discnum != None:
164 outfile = ''.join([discnum, ' ', outfile])
165 run_flac('wav', 'cue', '/'.join(['..', artist, outfile]), tags.all())
166 files = ['%s/%s.flac' % (artist, outfile)]
167
168 c(os.unlink, 'cue')
169 outlog = '/'.join(['..', artist, outfile + '.log'])
170 c(os.rename, 'log', outlog)
171 else:
172 title = tags.gets('TITLE', tracknum, separator).replace('/', '_')
173 tmp = []
174 if discnum != None:
175 tmp.append('%02d' % (int(discnum),))
176 tmp.extend(['%02d' % (tracknum,), title])
177 part = tags.gets('PART', tracknum)
178 if part != None:
179 tmp.extend(['-', part])
180 outfile = '/'.join([outdir, ' '.join(tmp)])
181
182 run_flac('track%02d.cdda.wav' % (tracknum,), None,
183 '../' + outfile, tags.track(tracknum))
184 outlog = ''.join(['../', outfile, '.log'])
185 files = [outfile + '.flac']
186
187 c(os.rename, str(tracknum) + '.log', outlog)
188
189 c(os.chdir, '..')
190
191 post = dir + '/post-processor'
192 if os.path.exists(post):
193 spew('Running ', post); spew(files); spew('\n')
194 files.insert(0, post)
195 os.spawnv(os.P_WAIT, post, files)
196 c(os.unlink, post)
197
198 # Clean up if we're the last job for dir; for multi-file dirs,
199 # it's possible for more than one job to run cleanup at once, so
200 # don't fail if things are already clean.
201 ld = os.listdir(dir)
202 if ld == ['using-tags'] or sorted(ld) == ['cover.front', 'using-tags']:
203 try:
204 try:
205 os.unlink(dir + '/cover.front')
206 except OSError:
207 pass
208 os.unlink(dir + '/using-tags')
209 os.rmdir(dir)
210 except EnvironmentError:
211 pass
212
213 return 0
214
215 ################################################################################
216 # The master process
217
218 def get_tags(fn):
219 """Return the ARTIST, ALBUM, and DATE followed by a list of all the
220 lines in the file FN."""
221
222 tags = Tags()
223 spew('Opening tags file %s\n' % (fn,))
224 tags.load(open(fn))
225
226 spew('ARTIST %s from %s\n' % (tags.gets('ARTIST'), fn))
227 spew('ALBUM %s from %s\n' % (tags.gets('ALBUM'), fn))
228 spew('DISCNUMBER %s from %s\n' % (tags.gets('DISCNUMBER'), fn))
229
230 return tags
231
232 def flacloop(maxjobs):
233 dir = [None] # [str] instead of str for lame python closures
234 jobs = []
235
236 # Get a job for jobs.run. On each call, look for new fa-rip
237 # directories and append an item to the queue @jobs for each wav
238 # file therein. Then, if we have anything in the queue, return a
239 # function to call flac for it, otherwise sleep for a bit. This
240 # looks forever, never returning None, so jobs.run never returns.
241 def getjob(reap):
242 # Look for new fa-rip directories.
243 while True:
244 for i in glob('*/tags'):
245 try:
246 dir[0] = os.path.dirname(i)
247
248 spew("Renaming %s/tags\n" % (dir[0],))
249 c(os.rename, dir[0] + '/tags', dir[0] + '/using-tags')
250
251 tags = get_tags(dir[0] + '/using-tags')
252 if os.path.exists(dir[0] + '/wav'):
253 # single-file
254 jobs.append((dir[0], None, tags))
255 else:
256 # multi-file
257 # Don't need cue file.
258 try:
259 c(os.unlink, dir[0] + '/cue')
260 except EnvironmentError, error:
261 if error.errno != ENOENT:
262 raise error
263
264 jobs.extend([(dir[0], x, tags)
265 for x in xrange(1, len(tags) + 1)])
266 except Exception, error:
267 sys.stderr.write(getattr(error, 'msg', ''))
268 traceback.print_exc()
269 sys.stderr.write('Continuing...\n')
270
271 # Return a job if we found any work.
272 try:
273 job = jobs.pop(0)
274 except IndexError:
275 # Didn't find anything; wait a while and check again.
276 time.sleep(5)
277 reap()
278 continue
279
280 def lamb():
281 log = '/'.join([job[0],
282 job[1] == None and 'log'
283 or str(job[1]) + '.log'])
284 try:
285 c(os.dup2, c(os.open, log, os.O_CREAT | os.O_WRONLY), 2)
286 return flac(*job)
287 except EnvironmentError, error:
288 sys.stderr.write(getattr(error, 'msg', ''))
289 traceback.print_exc()
290 return 1
291 return lamb
292
293 while True:
294 status = getjob(lambda *a,**k: None)()
295 spew('%d jobs; %d finished (' % (len(jobs), pid))
296 if os.WIFEXITED(status):
297 spew('exited with status ', str(os.WEXITSTATUS(status)))
298 elif os.WIFSIGNALED(status):
299 spew('killed with signal ', str(os.WTERMSIG(status)))
300 elif os.WIFSTOPPED(status):
301 spew('stopped with signal ', str(os.WSTOPSIG(status)))
302 spew(')\n')
303
304 def main(argv):
305 # Control the exit code for any uncaught exceptions.
306 try:
307 parser = OptionParser()
308 parser.disable_interspersed_args()
309 parser.add_option('-X', '--debug', action='store_true', default=False)
310 parser.add_option('-j', '--jobs', type='int', default=1)
311 parser.add_option('-v', '--verbose', action='store_true', default=False)
312 except:
313 traceback.print_exc()
314 return 2
315
316 try:
317 # Raises SystemExit on invalid options in argv.
318 (options, args) = parser.parse_args(argv[1:])
319 except Exception, error:
320 if isinstance(error, SystemExit):
321 return 1
322 traceback.print_exc()
323 return 2
324
325 try:
326 global debug, verbose
327 debug = options.debug
328 verbose = options.verbose
329
330 # Never returns...
331 flacloop(options.jobs)
332 except Exception, error:
333 # but might blow up.
334 if isinstance(error, SystemExit):
335 raise
336 sys.stderr.write(getattr(error, 'msg', ''))
337 traceback.print_exc()
338 return 2
339
340 if __name__ == '__main__':
341 sys.exit(main(sys.argv))