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