]> diplodocus.org Git - flac-archive/blob - fa-flacd
(flac2mp3): Don't need to int(track) now that the loop in main does it.
[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([discnum, ' ', outfile])
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 try:
259 c(os.unlink, dir[0] + '/cue')
260 except EnvironmentError, error:
261 if error.errno != ENOENT:
262 raise error
263
264 # Go over @tags, store all [n] tags in a list keyed by
265 # n in %tracks_to_tags, store all ARTIST (not
266 # ARTIST[n]) tags in @disc_artist, and leave the rest
267 # in @tags.
268 tracks_to_tags = ListDictDict()
269 disc_artist = []
270 tmp = []
271 for tag in tags:
272 m = re.match(r'([^[]+)\[(\d+)]=(.*)', tag)
273 if m != None:
274 tracks_to_tags.append(int(m.group(2)), m.group(1), m.group(3))
275 elif re.match(r'ARTIST=', tag, re.IGNORECASE):
276 disc_artist.append(tag)
277 else:
278 tmp.append(tag)
279 tags = tmp
280
281 jobs.extend([[dir[0], artist, album, discnum, x,
282 tracks_to_tags[x], disc_artist, tags]
283 for x in sorted(map(int,
284 tracks_to_tags.keys()))])
285 except Exception, error:
286 sys.stderr.write(getattr(error, 'msg', ''))
287 traceback.print_exc()
288 sys.stderr.write('Continuing...\n')
289
290 # Return a job if we found any work.
291 try:
292 job = jobs.pop(0)
293 except IndexError:
294 # Didn't find anything; wait a while and check again.
295 time.sleep(5)
296 reap()
297 continue
298
299 def lamb():
300 log = '/'.join([job[0],
301 job[4] == None and 'log' or str(job[4])
302 + '.log'])
303 try:
304 c(os.dup2, c(os.open, log, os.O_CREAT | os.O_WRONLY), 2)
305 return flac(*job)
306 except EnvironmentError, error:
307 sys.stderr.write(getattr(error, 'msg', ''))
308 traceback.print_exc()
309 return 1
310 return lamb
311
312 def notify_start(pid, jobs):
313 # make this print '2 jobs; start 378 for Artist/01 Title.flac'
314 spew('%d jobs; start %d for %s\n' % (len(jobs), pid, dir[0]))
315
316 def notify_finish(pid, status, jobs):
317 spew('%d jobs; %d finished (' % (len(jobs), pid))
318 if os.WIFEXITED(status):
319 spew('exited with status ', str(os.WEXITSTATUS(status)))
320 elif os.WIFSIGNALED(status):
321 spew('killed with signal ', str(os.WTERMSIG(status)))
322 elif os.WIFSTOPPED(status):
323 spew('stopped with signal ', str(os.WSTOPSIG(status)))
324 spew(')\n')
325
326 # Never returns (see getjob comment).
327 org.diplodocus.jobs.run(maxjobs=maxjobs, debug=debug,
328 get_job=getjob,
329 notify_start=notify_start,
330 notify_finish=notify_finish)
331
332 def main(argv):
333 # Control the exit code for any uncaught exceptions.
334 try:
335 parser = OptionParser()
336 parser.disable_interspersed_args()
337 parser.add_option('-X', '--debug', action='store_true', default=False)
338 parser.add_option('-j', '--jobs', type='int', default=1)
339 parser.add_option('-v', '--verbose', action='store_true', default=False)
340 except:
341 traceback.print_exc()
342 return 2
343
344 try:
345 # Raises SystemExit on invalid options in argv.
346 (options, args) = parser.parse_args(argv[1:])
347 except Exception, error:
348 if isinstance(error, SystemExit):
349 return 1
350 traceback.print_exc()
351 return 2
352
353 try:
354 global debug, verbose
355 debug = options.debug
356 verbose = options.verbose
357
358 # Never returns...
359 flacloop(options.jobs)
360 except Exception, error:
361 # but might blow up.
362 if isinstance(error, SystemExit):
363 raise
364 sys.stderr.write(getattr(error, 'msg', ''))
365 traceback.print_exc()
366 return 2
367
368 if __name__ == '__main__':
369 sys.exit(main(sys.argv))