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