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