]> diplodocus.org Git - flac-archive/blob - fa-flacd
Grab Amazon artwork, but don't do anything with it yet.
[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 ld = os.listdir(dir)
194 if ld == ['using-tags'] or sorted(ld) == ['cover.front', 'using-tags']:
195 try:
196 os.unlink(dir + '/cover.front')
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 artist = album = discnum = None
212 tags = []
213
214 spew('Opening tags file %s\n' % (fn,))
215 fp = file(fn)
216 for line in (x.rstrip() for x in fp):
217 tags.append(line)
218
219 (tag, value) = line.split('=', 1)
220
221 if re.match(r'ARTIST=', line, re.IGNORECASE):
222 artist = value
223 spew('ARTIST %s from %s\n' % (artist, fn))
224 elif re.match(r'ALBUM=', line, re.IGNORECASE):
225 album = value
226 spew('ALBUM %s from %s\n' % (album, fn))
227 elif re.match(r'DISCNUMBER=', line, re.IGNORECASE):
228 discnum = value
229 spew('DISCNUMBER %s from %s\n' % (discnum, fn))
230
231 return (artist, album, discnum, tags)
232
233 def flacloop(maxjobs):
234 dir = [None] # [str] instead of str for lame python closures
235 jobs = []
236
237 # Get a job for jobs.run. On each call, look for new fa-rip
238 # directories and append an item to the queue @jobs for each wav
239 # file therein. Then, if we have anything in the queue, return a
240 # function to call flac for it, otherwise sleep for a bit. This
241 # looks forever, never returning None, so jobs.run never returns.
242 def getjob(reap):
243 # Look for new fa-rip directories.
244 while True:
245 for i in glob('*/tags'):
246 try:
247 dir[0] = os.path.dirname(i)
248
249 spew("Renaming %s/tags\n" % (dir[0],))
250 c(os.rename, dir[0] + '/tags', dir[0] + '/using-tags')
251
252 (artist, album, discnum, tags) = get_tags(dir[0] + '/using-tags')
253 if os.path.exists(dir[0] + '/wav'):
254 # single-file
255 jobs.append([dir[0], artist, album, discnum,
256 None, None, None, tags])
257 else:
258 # multi-file
259 # Don't need cue file.
260 try:
261 c(os.unlink, dir[0] + '/cue')
262 except EnvironmentError, error:
263 if error.errno != ENOENT:
264 raise error
265
266 # Go over @tags, store all [n] tags in a list keyed by
267 # n in %tracks_to_tags, store all ARTIST (not
268 # ARTIST[n]) tags in @disc_artist, and leave the rest
269 # in @tags.
270 tracks_to_tags = ListDictDict()
271 disc_artist = []
272 tmp = []
273 for tag in tags:
274 m = re.match(r'([^[]+)\[(\d+)]=(.*)', tag)
275 if m != None:
276 tracks_to_tags.append(int(m.group(2)), m.group(1), m.group(3))
277 elif re.match(r'ARTIST=', tag, re.IGNORECASE):
278 disc_artist.append(tag)
279 else:
280 tmp.append(tag)
281 tags = tmp
282
283 jobs.extend([[dir[0], artist, album, discnum, x,
284 tracks_to_tags[x], disc_artist, tags]
285 for x in sorted(map(int,
286 tracks_to_tags.keys()))])
287 except Exception, error:
288 sys.stderr.write(getattr(error, 'msg', ''))
289 traceback.print_exc()
290 sys.stderr.write('Continuing...\n')
291
292 # Return a job if we found any work.
293 try:
294 job = jobs.pop(0)
295 except IndexError:
296 # Didn't find anything; wait a while and check again.
297 time.sleep(5)
298 reap()
299 continue
300
301 def lamb():
302 log = '/'.join([job[0],
303 job[4] == None and 'log' or str(job[4])
304 + '.log'])
305 try:
306 c(os.dup2, c(os.open, log, os.O_CREAT | os.O_WRONLY), 2)
307 return flac(*job)
308 except EnvironmentError, error:
309 sys.stderr.write(getattr(error, 'msg', ''))
310 traceback.print_exc()
311 return 1
312 return lamb
313
314 def notify_start(pid, jobs):
315 # make this print '2 jobs; start 378 for Artist/01 Title.flac'
316 spew('%d jobs; start %d for %s\n' % (len(jobs), pid, dir[0]))
317
318 def notify_finish(pid, status, jobs):
319 spew('%d jobs; %d finished (' % (len(jobs), pid))
320 if os.WIFEXITED(status):
321 spew('exited with status ', str(os.WEXITSTATUS(status)))
322 elif os.WIFSIGNALED(status):
323 spew('killed with signal ', str(os.WTERMSIG(status)))
324 elif os.WIFSTOPPED(status):
325 spew('stopped with signal ', str(os.WSTOPSIG(status)))
326 spew(')\n')
327
328 # Never returns (see getjob comment).
329 org.diplodocus.jobs.run(maxjobs=maxjobs, debug=debug,
330 get_job=getjob,
331 notify_start=notify_start,
332 notify_finish=notify_finish)
333
334 def main(argv):
335 # Control the exit code for any uncaught exceptions.
336 try:
337 parser = OptionParser()
338 parser.disable_interspersed_args()
339 parser.add_option('-X', '--debug', action='store_true', default=False)
340 parser.add_option('-j', '--jobs', type='int', default=1)
341 parser.add_option('-v', '--verbose', action='store_true', default=False)
342 except:
343 traceback.print_exc()
344 return 2
345
346 try:
347 # Raises SystemExit on invalid options in argv.
348 (options, args) = parser.parse_args(argv[1:])
349 except Exception, error:
350 if isinstance(error, SystemExit):
351 return 1
352 traceback.print_exc()
353 return 2
354
355 try:
356 global debug, verbose
357 debug = options.debug
358 verbose = options.verbose
359
360 # Never returns...
361 flacloop(options.jobs)
362 except Exception, error:
363 # but might blow up.
364 if isinstance(error, SystemExit):
365 raise
366 sys.stderr.write(getattr(error, 'msg', ''))
367 traceback.print_exc()
368 return 2
369
370 if __name__ == '__main__':
371 sys.exit(main(sys.argv))