]> diplodocus.org Git - flac-archive/blob - fa-flacd
Here's another silly setup.py.
[flac-archive] / fa-flacd
1 #! /usr/bin/env python2.4
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 ''' #' # python-mode is sucks
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 import org.diplodocus.jobs
89 from org.diplodocus.structures import ListDictDict
90 from org.diplodocus.util import die, flatten, nothing
91 from org.diplodocus.util import catch_EnvironmentError as c
92
93 def spew(*args):
94 if verbose:
95 for i in args:
96 sys.stderr.write(i)
97
98 ################################################################################
99 # The child processes
100
101 def run_flac(infile, cue, outfile, tags):
102 argv = ['flac', '-o', outfile + '.flac-tmp',
103 '--delete-input-file', '-V', '--no-padding', '--best']
104 if cue != None:
105 argv.extend(['--cuesheet', cue])
106 for i in tags:
107 argv.extend(['-T', i])
108 argv.append(infile)
109
110 spew('Running flac\n')
111 status = os.spawnvp(os.P_WAIT, argv[0], argv)
112 if status > 0:
113 die(2, 'flac exited with status ', str(status))
114 elif status < 0:
115 die(2, 'flac killed with signal ', str(abs(status)))
116
117 c(os.rename, outfile + '.flac-tmp', outfile + '.flac')
118
119 def flac(dir, artist, album, discnum, tracknum, track_tags, disc_artist, tags):
120 '''Encode a single wav file to a single flac file, whether the wav and
121 flac files represent individual tracks or whole discs.'''
122
123 (artist, album) = [x.replace('/', '_') for x in (artist, album)]
124
125 spew('mkdir(%s)\n' % (artist,))
126 try:
127 c(os.mkdir, artist)
128 except EnvironmentError, error:
129 error.errno == EEXIST or die(2, error.msg, traceback.format_exc())
130
131 if tracknum != None:
132 outdir = '/'.join([artist, album])
133 spew('mkdir(%s)\n' % (outdir,))
134 try:
135 c(os.mkdir, outdir)
136 except EnvironmentError, error:
137 error.errno == EEXIST or die(2, error.msg, traceback.format_exc())
138
139 spew('chdir(%s)\n' % (dir,))
140 c(os.chdir, dir)
141
142 if tracknum == None:
143 outfile = album
144 if discnum != None:
145 outfile = ''.join([outfile, ' (disc ', discnum, ')'])
146 run_flac('wav', 'cue', '/'.join(['..', artist, outfile]), tags)
147 files = ['%s/%s.flac' % (artist, outfile)]
148
149 c(os.unlink, 'cue')
150 outlog = '/'.join(['..', artist, outfile + '.log'])
151 c(os.rename, 'log', outlog)
152 else:
153 title = []
154 for i in track_tags['TITLE']:
155 title.extend(i.split())
156 title = ' '.join(title).replace('/', '_')
157 tmp = []
158 if discnum != None:
159 tmp.append('%02d' % (int(discnum),))
160 tmp.extend(['%02d' % (tracknum,), title])
161 outfile = '/'.join([outdir, ' '.join(tmp)])
162
163 tags = tags[:]
164 # If we have ARTIST[n] tags for this track, they'll go in with
165 # the other [n] tags. Else, prepend disc_artist to tags.
166 if 'ARTIST' not in track_tags:
167 for i in disc_artist:
168 tags.insert(0, i)
169
170 tags.append('TRACKNUMBER=%d' % (tracknum,))
171 tags.extend(flatten([['='.join([key, x]) for x in track_tags[key]]
172 for key in track_tags]))
173
174 run_flac('track%02d.cdda.wav' % (tracknum,), None,
175 '../' + outfile, tags)
176 outlog = ''.join(['../', outfile, '.log'])
177 files = [outfile + '.flac']
178
179 c(os.rename, str(tracknum) + '.log', outlog)
180
181 c(os.chdir, '..')
182
183 post = dir + '/post-processor'
184 if os.path.exists(post):
185 spew('Running ', post); spew(files); spew('\n')
186 files.insert(0, post)
187 os.spawnv(os.P_WAIT, post, files)
188 c(os.unlink, post)
189
190 # Clean up if we're the last job for dir; for multi-file dirs,
191 # it's possible for more than one job to run cleanup at once, so
192 # don't fail if things are already clean.
193 if os.listdir(dir) == ['using-tags']:
194 try:
195 os.unlink(dir + '/using-tags')
196 os.rmdir(dir)
197 except EnvironmentError:
198 pass
199
200 return 0
201
202 ################################################################################
203 # The master process
204
205 def get_tags(fn):
206 '''Return the ARTIST, ALBUM, and DATE followed by a list of all the
207 lines in the file FN.'''
208
209 artist = album = discnum = None
210 tags = []
211
212 spew('Opening tags file %s\n' % (fn,))
213 fp = file(fn)
214 for line in (x.rstrip() for x in fp):
215 tags.append(line)
216
217 (tag, value) = line.split('=', 1)
218
219 if re.match(r'ARTIST=', line, re.IGNORECASE):
220 artist = value
221 spew('ARTIST %s from %s\n' % (artist, fn))
222 elif re.match(r'ALBUM=', line, re.IGNORECASE):
223 album = value
224 spew('ALBUM %s from %s\n' % (album, fn))
225 elif re.match(r'DISCNUMBER=', line, re.IGNORECASE):
226 discnum = value
227 spew('DISCNUMBER %s from %s\n' % (discnum, fn))
228
229 return (artist, album, discnum, tags)
230
231 def flacloop(maxjobs):
232 dir = [None] # [str] instead of str for lame python closures
233 jobs = []
234
235 # Get a job for jobs.run. On each call, look for new fa-rip
236 # directories and append an item to the queue @jobs for each wav
237 # file therein. Then, if we have anything in the queue, return a
238 # function to call flac for it, otherwise sleep for a bit. This
239 # looks forever, never returning None, so jobs.run never returns.
240 def getjob(reap):
241 # Look for new fa-rip directories.
242 while True:
243 for i in glob('*/tags'):
244 try:
245 dir[0] = os.path.dirname(i)
246
247 spew("Renaming %s/tags\n" % (dir[0],))
248 c(os.rename, dir[0] + '/tags', dir[0] + '/using-tags')
249
250 (artist, album, discnum, tags) = get_tags(dir[0] + '/using-tags')
251 if os.path.exists(dir[0] + '/wav'):
252 # single-file
253 jobs.append([dir[0], artist, album, discnum,
254 None, None, None, tags])
255 else:
256 # multi-file
257 # Don't need cue file.
258 c(os.unlink, dir[0] + '/cue')
259
260 # Go over @tags, store all [n] tags in a list keyed by
261 # n in %tracks_to_tags, store all ARTIST (not
262 # ARTIST[n]) tags in @disc_artist, and leave the rest
263 # in @tags.
264 tracks_to_tags = ListDictDict()
265 disc_artist = []
266 tmp = []
267 for tag in tags:
268 m = re.match(r'([^[]+)\[(\d+)]=(.*)', tag)
269 if m != None:
270 tracks_to_tags.append(int(m.group(2)), m.group(1), m.group(3))
271 elif re.match(r'ARTIST=', tag, re.IGNORECASE):
272 disc_artist.append(tag)
273 else:
274 tmp.append(tag)
275 tags = tmp
276
277 jobs.extend([[dir[0], artist, album, discnum, x,
278 tracks_to_tags[x], disc_artist, tags]
279 for x in sorted(map(int,
280 tracks_to_tags.keys()))])
281 except Exception, error:
282 sys.stderr.write(getattr(error, 'msg', ''))
283 traceback.print_exc()
284 sys.stderr.write('Continuing...\n')
285
286 # Return a job if we found any work.
287 try:
288 job = jobs.pop(0)
289 except IndexError:
290 # Didn't find anything; wait a while and check again.
291 time.sleep(5)
292 reap()
293 continue
294
295 def lamb():
296 log = '/'.join([job[0],
297 job[4] == None and 'log' or str(job[4])
298 + '.log'])
299 try:
300 c(os.dup2, c(os.open, log, os.O_CREAT | os.O_WRONLY), 2)
301 return flac(*job)
302 except EnvironmentError, error:
303 sys.stderr.write(getattr(error, 'msg', ''))
304 traceback.print_exc()
305 return 1
306 return lamb
307
308 def notify_start(pid, jobs):
309 # make this print '2 jobs; start 378 for Artist/01 Title.flac'
310 spew('%d jobs; start %d for %s\n' % (len(jobs), pid, dir[0]))
311
312 def notify_finish(pid, status, jobs):
313 spew('%d jobs; %d finished (' % (len(jobs), pid))
314 if os.WIFEXITED(status):
315 spew('exited with status ', str(os.WEXITSTATUS(status)))
316 elif os.WIFSIGNALED(status):
317 spew('killed with signal ', str(os.WTERMSIG(status)))
318 elif os.WIFSTOPPED(status):
319 spew('stopped with signal ', str(os.WSTOPSIG(status)))
320 spew(')\n')
321
322 # Never returns (see getjob comment).
323 org.diplodocus.jobs.run(maxjobs=maxjobs, debug=debug,
324 get_job=getjob,
325 notify_start=notify_start,
326 notify_finish=notify_finish)
327
328 def main(argv):
329 # Control the exit code for any uncaught exceptions.
330 try:
331 parser = OptionParser()
332 parser.disable_interspersed_args()
333 parser.add_option('-X', '--debug', action='store_true', default=False)
334 parser.add_option('-j', '--jobs', type='int', default=1)
335 parser.add_option('-v', '--verbose', action='store_true', default=False)
336 except:
337 traceback.print_exc()
338 return 2
339
340 try:
341 # Raises SystemExit on invalid options in argv.
342 (options, args) = parser.parse_args(argv[1:])
343 except Exception, error:
344 if isinstance(error, SystemExit):
345 return 1
346 traceback.print_exc()
347 return 2
348
349 try:
350 global debug, verbose
351 debug = options.debug
352 verbose = options.verbose
353
354 # Never returns...
355 flacloop(options.jobs)
356 except Exception, error:
357 # but might blow up.
358 if isinstance(error, SystemExit):
359 raise
360 sys.stderr.write(getattr(error, 'msg', ''))
361 traceback.print_exc()
362 return 2
363
364 if __name__ == '__main__':
365 sys.exit(main(sys.argv))