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