]> diplodocus.org Git - flac-archive/blob - fa-flacd
Fix bugs:
[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 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 c(os.unlink, 'cue')
162 outlog = '/'.join(['..', artist, outfile + '.log'])
163 c(os.rename, 'log', outlog)
164 else:
165 title = tags.gets('TITLE', tracknum, separator).replace('/', '_')
166 tmp = []
167 if discnum != None:
168 tmp.append('%02d' % (int(discnum),))
169 tmp.extend(['%02d' % (tracknum,), title])
170 part = tags.gets('PART', tracknum)
171 if part != None:
172 tmp.extend(['-', part])
173 outfile = '/'.join([outdir, ' '.join(tmp)])
174
175 run_flac('track%02d.cdda.wav' % (tracknum,), None,
176 '../' + outfile, tags.track(tracknum))
177 outlog = ''.join(['../', outfile, '.log'])
178 files = [outfile + '.flac']
179
180 c(os.rename, str(tracknum) + '.log', outlog)
181
182 c(os.chdir, '..')
183
184 post = dir + '/post-processor'
185 if os.path.exists(post):
186 spew('Running ', post); spew(files); spew('\n')
187 files.insert(0, post)
188 os.spawnv(os.P_WAIT, post, files)
189 c(os.unlink, post)
190
191 # Clean up if we're the last job for dir; for multi-file dirs,
192 # it's possible for more than one job to run cleanup at once, so
193 # don't fail if things are already clean.
194 ld = os.listdir(dir)
195 if ld == ['using-tags'] or sorted(ld) == ['cover.front', 'using-tags']:
196 try:
197 try:
198 os.unlink(dir + '/cover.front')
199 except OSError:
200 pass
201 os.unlink(dir + '/using-tags')
202 os.rmdir(dir)
203 except EnvironmentError:
204 pass
205
206 return 0
207
208 ################################################################################
209 # The master process
210
211 def get_tags(fn):
212 """Return the ARTIST, ALBUM, and DATE followed by a list of all the
213 lines in the file FN."""
214
215 tags = Tags()
216 spew('Opening tags file %s\n' % (fn,))
217 tags.load(open(fn))
218
219 spew('ARTIST %s from %s\n' % (tags.gets('ARTIST'), fn))
220 spew('ALBUM %s from %s\n' % (tags.gets('ALBUM'), fn))
221 spew('DISCNUMBER %s from %s\n' % (tags.gets('DISCNUMBER'), fn))
222
223 return tags
224
225 def flacloop(maxjobs):
226 dir = [None] # [str] instead of str for lame python closures
227 jobs = []
228
229 # Get a job for jobs.run. On each call, look for new fa-rip
230 # directories and append an item to the queue @jobs for each wav
231 # file therein. Then, if we have anything in the queue, return a
232 # function to call flac for it, otherwise sleep for a bit. This
233 # looks forever, never returning None, so jobs.run never returns.
234 def getjob(reap):
235 # Look for new fa-rip directories.
236 while True:
237 for i in glob('*/tags'):
238 try:
239 dir[0] = os.path.dirname(i)
240
241 spew("Renaming %s/tags\n" % (dir[0],))
242 c(os.rename, dir[0] + '/tags', dir[0] + '/using-tags')
243
244 tags = get_tags(dir[0] + '/using-tags')
245 if os.path.exists(dir[0] + '/wav'):
246 # single-file
247 jobs.append((dir[0], None, tags))
248 else:
249 # multi-file
250 # Don't need cue file.
251 try:
252 c(os.unlink, dir[0] + '/cue')
253 except EnvironmentError, error:
254 if error.errno != ENOENT:
255 raise error
256
257 jobs.extend([(dir[0], x, tags)
258 for x in xrange(1, len(tags) + 1)])
259 except Exception, error:
260 sys.stderr.write(getattr(error, 'msg', ''))
261 traceback.print_exc()
262 sys.stderr.write('Continuing...\n')
263
264 # Return a job if we found any work.
265 try:
266 job = jobs.pop(0)
267 except IndexError:
268 # Didn't find anything; wait a while and check again.
269 time.sleep(5)
270 reap()
271 continue
272
273 def lamb():
274 log = '/'.join([job[0],
275 job[1] == None and 'log'
276 or str(job[1]) + '.log'])
277 try:
278 c(os.dup2, c(os.open, log, os.O_CREAT | os.O_WRONLY), 2)
279 return flac(*job)
280 except EnvironmentError, error:
281 sys.stderr.write(getattr(error, 'msg', ''))
282 traceback.print_exc()
283 return 1
284 return lamb
285
286 def notify_start(pid, jobs):
287 # make this print '2 jobs; start 378 for Artist/01 Title.flac'
288 spew('%d jobs; start %d for %s\n' % (len(jobs), pid, dir[0]))
289
290 def notify_finish(pid, status, jobs):
291 spew('%d jobs; %d finished (' % (len(jobs), pid))
292 if os.WIFEXITED(status):
293 spew('exited with status ', str(os.WEXITSTATUS(status)))
294 elif os.WIFSIGNALED(status):
295 spew('killed with signal ', str(os.WTERMSIG(status)))
296 elif os.WIFSTOPPED(status):
297 spew('stopped with signal ', str(os.WSTOPSIG(status)))
298 spew(')\n')
299
300 # Never returns (see getjob comment).
301 org.diplodocus.jobs.run(maxjobs=maxjobs, debug=debug,
302 get_job=getjob,
303 notify_start=notify_start,
304 notify_finish=notify_finish)
305
306 def main(argv):
307 # Control the exit code for any uncaught exceptions.
308 try:
309 parser = OptionParser()
310 parser.disable_interspersed_args()
311 parser.add_option('-X', '--debug', action='store_true', default=False)
312 parser.add_option('-j', '--jobs', type='int', default=1)
313 parser.add_option('-v', '--verbose', action='store_true', default=False)
314 except:
315 traceback.print_exc()
316 return 2
317
318 try:
319 # Raises SystemExit on invalid options in argv.
320 (options, args) = parser.parse_args(argv[1:])
321 except Exception, error:
322 if isinstance(error, SystemExit):
323 return 1
324 traceback.print_exc()
325 return 2
326
327 try:
328 global debug, verbose
329 debug = options.debug
330 verbose = options.verbose
331
332 # Never returns...
333 flacloop(options.jobs)
334 except Exception, error:
335 # but might blow up.
336 if isinstance(error, SystemExit):
337 raise
338 sys.stderr.write(getattr(error, 'msg', ''))
339 traceback.print_exc()
340 return 2
341
342 if __name__ == '__main__':
343 sys.exit(main(sys.argv))