lossfully 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,614 @@
1
+ #--
2
+ # Copyright (C) 2011 Don March
3
+ #
4
+ # This file is part of Lossfully.
5
+ #
6
+ # Lossfully is free software: you can redistribute it and/or modify it
7
+ # under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # Lossfully is distributed in the hope that it will be useful, but
12
+ # WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14
+ # General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see
18
+ # <http://www.gnu.org/licenses/>.
19
+ #++
20
+
21
+ require 'find'
22
+ require 'pathname'
23
+ require 'erb'
24
+ require 'fileutils'
25
+
26
+ # TODO: test if necessary binaries are installed
27
+ # TODO: test if sox compiled with LAME
28
+ # check and update metadata
29
+
30
+ module Lossfully
31
+
32
+ # Lossfully works in a sort of declarative way of adding rules that
33
+ # match certain files and indicate what action to take on those
34
+ # files.
35
+ #
36
+ # There are six main methods that are used to add rules for how to
37
+ # handle different types of files, and they all behave relatively
38
+ # similarly: encode, options, effect_options, path, clobber, and
39
+ # remove_missing. All of the rules created by a given method are
40
+ # collected as InputRules (which see) and are sorted in rough order
41
+ # of strictness. When the generator is finally run, each rule is
42
+ # tried in succession (in order of strictness) until one matches the
43
+ # file, and that action is used. Each method adds rules for how to
44
+ # encode files, except for remove_missing, which determines what
45
+ # pre-existing files to remove from the target directory upon
46
+ # completion.
47
+ #
48
+ # Each rule-making method takes a hash where the key specifies what
49
+ # files the new rule should apply to, and the value specifies what
50
+ # the action is. The key part of the hash is a single object or an
51
+ # array of the following:
52
+ #
53
+ # * A symbol, which matches the type of the file as returned by
54
+ # `soxi -t' (e.g., :vorbis) or a type class (:everything, :audio,
55
+ # :nonaudio, :lossy, :lossless). The symbol :ogg is treated as a
56
+ # synonym for :vorbis.
57
+ #
58
+ # * A string that specifies a file extension to match.
59
+ #
60
+ # * A regular expression.
61
+ #
62
+ # * A number, which specifies the minimum bitrate in kbps a matching
63
+ # file must have.
64
+ #
65
+ # The allowed values for the key specifying the action depends on
66
+ # the method.
67
+ #
68
+ # A rule can omit the key part of the hash and simply specify the
69
+ # rule, and a default will be used (:everything for clobber and
70
+ # remove_missing, :audio for the others).
71
+ #
72
+ # A rule can also omit the value (action) part of the hash if a
73
+ # block is given. The block must return nil or false when the rule
74
+ # does not apply to a file; otherwise the block should return the
75
+ # action to perform on the matching files. An AudioFile instance
76
+ # will be yielded to the block regardless of whether the current
77
+ # file is actually audio or not. See the documentation for
78
+ # AudioFile for available methods.
79
+ #
80
+ # So, for example, the following are possible uses of the encode
81
+ # method:
82
+ #
83
+ # encode [:lossy, 320, /Bach/] => '.mp3'
84
+ #
85
+ # encode [:lossy, 320, /Bach/] do
86
+ # if # conditions here
87
+ # ['.ogg', 6]
88
+ # elsif # other conditions
89
+ # ['.mp3', -192.2]
90
+ # else
91
+ # false
92
+ # end
93
+ # end
94
+ #
95
+ # The methods copy and skip are aliases for encode with :skip or
96
+ # :copy as the action.
97
+ #
98
+ # The methods quiet, threads, and rel_path just set options and do
99
+ # not create rules.
100
+ #
101
+ class Generator
102
+
103
+ private
104
+
105
+ def self.sort_by_rules array
106
+ array.sort { |x,y| (x[0] <=> y[0]) rescue 0 }
107
+ end
108
+
109
+ def self.sort_by_rules! array
110
+ array.sort! { |x,y| (x[0] <=> y[0]) rescue 0 }
111
+ end
112
+
113
+ def self.determine_rule array, file
114
+ array.each do |r|
115
+ if result = r[0].test(file)
116
+ return (result == true) ? r[1] : result
117
+ end
118
+ end
119
+ return nil
120
+ end
121
+
122
+ public
123
+
124
+ # Create a new Generator instance to store rules and run them.
125
+ #
126
+ # If a block is given, yeild self if arity of block is 1,
127
+ # otherwise run instance_eval on the block.
128
+ #
129
+ # Normally you would just call Lossfully.generate (which is a
130
+ # wrapper around this and #generate) with a source
131
+ # library/playlist and a target.
132
+ #
133
+ def initialize &block
134
+ @encode_rules = Array.new
135
+ @path_rules = Array.new
136
+ @option_rules = Array.new
137
+ @effect_option_rules = Array.new
138
+ @clobber_rules = Array.new
139
+ @remove_missing_rules = Array.new
140
+
141
+ @verbosity = 1
142
+ @threads = 1
143
+
144
+ # set default rules for commands
145
+ encode :everything => :copy
146
+ path :everything => :original
147
+ clobber :everything => true
148
+ remove_missing :everything => true
149
+ options :audio => ''
150
+ effect_options :audio => ''
151
+
152
+ if block_given?
153
+ if block.arity == 1
154
+ yield self
155
+ else
156
+ instance_eval(&block)
157
+ end
158
+ end
159
+ end
160
+
161
+ attr_accessor :verbosity
162
+ attr_writer :rel_path, :threads
163
+
164
+ # Return the number of threads to use for encoding to n_threads,
165
+ # or set the number of threads to n_threads if called with an
166
+ # argument.
167
+ #
168
+ def threads n_threads=nil
169
+ if n_threads
170
+ @threads = n_threads
171
+ end
172
+ @threads
173
+ end
174
+
175
+ # Return or set the relative path used when expanding the source
176
+ # and target directories. The default is to expand relative to
177
+ # the script called on the command line, i.e. $0.
178
+ #
179
+ def rel_path arg=nil
180
+ if arg
181
+ @rel_path = File.directory?(arg) ? arg : File.dirname(arg)
182
+ else
183
+ rel_path $0
184
+ end
185
+ end
186
+
187
+ # Turn on or off the progress for checking and acting on each
188
+ # file.
189
+ #
190
+ def quiet bool=true
191
+ @verbosity = bool ? 0 : 1
192
+ end
193
+
194
+ # Run the rules collected in this instance on every file in the
195
+ # source, placing the results in the target directory. Takes a
196
+ # pair of strings either as individual arguments or as a Hash,
197
+ # (see example below). The source can be a directory or a playlist.
198
+ #
199
+ # Normally you would just call Lossfully.generate (which is a
200
+ # wrapper around this and Generator.new) with a source
201
+ # library/playlist and a target. But if you want to use the same
202
+ # rules on several directories or playlists, you can create the
203
+ # Generator once and then call #generate on it several times with
204
+ # different arguments
205
+ #
206
+ # g = Lossfully::Generator new do
207
+ # remove_missing false
208
+ # encode :lossless => '.ogg'
209
+ # # ...
210
+ # end
211
+ #
212
+ # g.generate 'dir1', 'target'
213
+ # g.generate 'dir2' => 'target'
214
+ #
215
+ def generate *args
216
+ if args.size == 1 then
217
+ hash = args[0]
218
+ raise "Target not specified" unless hash.kind_of? Hash
219
+ raise "Hash must have only one key-value pair." unless hash.size == 1
220
+ primary = hash.keys.first
221
+ target = hash.values.first
222
+ else
223
+ raise "Input incorrectly specified." if args.size > 2
224
+ primary = args[0]
225
+ target = args[1]
226
+ end
227
+
228
+ raise "Converting from multiple libraries is not supported" if primary.kind_of? Array
229
+ raise "Writing to multiple directories is not supported" if target.kind_of? Array
230
+ primary = File.expand_path primary, File.dirname(rel_path())
231
+ target = File.expand_path target, File.dirname(rel_path())
232
+
233
+ raise "Overwriting original library not supported." if primary == target
234
+
235
+ [@encode_rules,
236
+ @path_rules,
237
+ @option_rules,
238
+ @effect_option_rules,
239
+ @clobber_rules,
240
+ @remove_missing_rules].each do |a|
241
+ self.class.sort_by_rules! a
242
+ end
243
+
244
+ encode_actions = ThreadPool.new(@threads)
245
+ copy_actions = ThreadPool.new(1)
246
+
247
+ int_level = 0
248
+ trap("INT") do
249
+ if int_level == 0
250
+ int_level += 1
251
+ message "\nWill stop after current processes are finished; press CTRL-C again to stop immediately."
252
+ encode_actions.stop
253
+ copy_actions.stop
254
+ abort
255
+ else
256
+ encode_actions.kill
257
+ copy_actions.kill
258
+ end
259
+ end
260
+
261
+ files_to_keep = []
262
+
263
+ if File.file? primary
264
+ files = File.readlines(primary).map { |f| f.chomp }.uniq
265
+ if File.extname(primary) == '.cue'
266
+ file = files.select { |f| f =~ /^FILE/ }
267
+ files.map! { |f| f.match(/"(.*[^\\])"/)[1] }
268
+ end
269
+ primary = File.dirname(primary)
270
+ files.map! { |f| File.expand_path(f.strip, primary) }
271
+ files = files.select { |f| File.file? f }
272
+ files.uniq!
273
+ else
274
+ files = []
275
+ Find.find(primary) { |f| files << f unless File.directory? f}
276
+ end
277
+
278
+ files.each_with_index do |file, file_index|
279
+ next if File.directory? file
280
+
281
+ file_index += 1
282
+ file_rel_name = Pathname.new(file).relative_path_from(Pathname.new(primary))
283
+ file_rel_name = File.basename(primary) + '/' + file_rel_name.to_s
284
+ message "check [#{file_index}/#{files.size}] " + file_rel_name
285
+
286
+ # By making `file' into an AudioFile we gain memoization,
287
+ # which actually speeds things up quite a bit for some hard
288
+ # drives.
289
+ file = AudioFile.new(file)
290
+
291
+ encoding = self.class.determine_rule @encode_rules, file
292
+ encoding = Array(encoding) unless encoding.kind_of? Array
293
+
294
+ next if encoding[0] == :skip
295
+ path = determine_path file, primary, target, encoding
296
+ files_to_keep << path
297
+
298
+ if File.exist? path
299
+ clobber = self.class.determine_rule @clobber_rules, file
300
+ next unless clobber # if clobber == false
301
+ if clobber.kind_of? String
302
+ path = path.chomp(File.extname(path)) + clobber + File.extname(path)
303
+ files_to_keep << path
304
+ elsif clobber == :rename
305
+ i = '1'
306
+ begin
307
+ new_path = path.chomp(File.extname(path)) + " (#{i.succ!})" + File.extname(path)
308
+ files_to_keep << new_path
309
+ end while File.exist? new_path
310
+ path = new_path
311
+ end
312
+ end
313
+
314
+ path_rel_name = Pathname.new(path).relative_path_from(Pathname.new(target))
315
+ path_rel_name = File.basename(target) + '/' + path_rel_name.to_s
316
+ # copy rather than reencoding if possible
317
+ if encoding[0] == :copy ||
318
+ (encoding[0] != :reencode &&
319
+ (File.extname(path) == File.extname(file.path) && encoding[1].nil?))
320
+ n = copy_actions.total + 1
321
+ copy_actions << lambda do
322
+ message "copy [#{n}/#{copy_actions.total}] " + path_rel_name
323
+ FileUtils.mkdir_p File.dirname(path)
324
+ FileUtils.cp(file.path, path)
325
+ end
326
+ else
327
+ options = self.class.determine_rule @option_rules, file
328
+ effect_options = self.class.determine_rule @effect_option_rules, file
329
+ n = encode_actions.total + 1
330
+ encode_actions << lambda do
331
+ message "encode [#{n}/#{encode_actions.total}] " + path_rel_name
332
+ FileUtils.mkdir_p File.dirname(path)
333
+ options = "-C #{encoding[1]} " + options if encoding[1].kind_of? Numeric
334
+ file.encode path, options, effect_options
335
+ end
336
+ end
337
+ end
338
+
339
+ encode_actions.join
340
+ copy_actions.join
341
+
342
+ files_to_keep.uniq!
343
+ Find.find(target) do |f|
344
+ next if ! File.exist? f
345
+ next if File.directory? f
346
+ if self.class.determine_rule @remove_missing_rules, f
347
+ FileUtils.rm f unless files_to_keep.include? f
348
+ end
349
+ end
350
+
351
+ delete_empty_directories(target)
352
+ end
353
+
354
+ private
355
+
356
+ # Print + "\n" is used instead of puts because sometimes two puts
357
+ # strings are printed together and then the newlines together. I
358
+ # guess because of threading.
359
+ #
360
+ def message str
361
+ print str + "\n" unless @verbosity == 0
362
+ end
363
+
364
+ def delete_empty_directories directory
365
+ Dir.entries(directory).delete_if {|x| x =~ /^\.+$/ }.each do |dir|
366
+ dir = File.join(directory, dir)
367
+ next unless File.directory?(dir)
368
+ delete_empty_directories dir unless dir == directory
369
+ FileUtils.rm_r dir if Dir.entries(dir).delete_if { |x| x=~ /^\.+$/ }.empty?
370
+ end
371
+ end
372
+
373
+ def determine_path audiofile_or_path, primary, target, encoding
374
+ file = audiofile_or_path.kind_of?(AudioFile) ? audiofile_or_path.path : audiofile_or_path
375
+
376
+ path = self.class.determine_rule @path_rules, audiofile_or_path
377
+ if path == :original
378
+ relative = Pathname.new(file).relative_path_from(Pathname.new(primary))
379
+ path = File.expand_path(File.join(target, relative))
380
+ path = path.chomp(File.extname path)
381
+ else
382
+ # Not done yet:
383
+ artist = artist
384
+ album = album
385
+ track = track
386
+ title = title
387
+ path = ERB.new(File.join(@target, path)).result(binding)
388
+ end
389
+
390
+ ext = if [:reencode, :copy].include?(encoding[0])
391
+ File.extname file
392
+ else
393
+ encoding[0]
394
+ end
395
+ ext = '.' + ext if ext[0..0] != '.'
396
+
397
+ return path + ext
398
+ end
399
+
400
+ def separate_input arg, default_input, default_output='', &block
401
+ if arg.kind_of? Hash
402
+ raise "Hash must have one key-value pair." unless arg.size == 1
403
+ input = arg.keys.first
404
+ output = arg.values.first
405
+ else
406
+ if block
407
+ input = !arg.nil? ? arg : default_input
408
+ output = default_output
409
+ else
410
+ input = default_input
411
+ output = !arg.nil? ? arg : default_output
412
+ end
413
+ end
414
+ input = Array(input) unless input.kind_of? Array
415
+ return input, output
416
+ end
417
+
418
+ public
419
+
420
+ # Set a rule for how to encode a matching file. See the common
421
+ # documentation for Generator for input format. The action is a
422
+ # single object or an array consiting of a string indicating the
423
+ # new extension of the encoded file, and/or a number which is
424
+ # passed as the compression/quality level (see the -C,
425
+ # --compression option in the man pages for sox and soxformat).
426
+ #
427
+ # Normally a matched file will not be reencoded if the output is
428
+ # to have the same file extension, unless a specific compression
429
+ # level is given as well; then it will be reencoded.
430
+ #
431
+ # To force a file to be reencoded at the default compression level
432
+ # (for example, in order to update changed metadata) use the
433
+ # symbol :reencode as the rule.
434
+ #
435
+ # Alternatively, the rule can be either :copy or :skip, which have
436
+ # the obvious result.
437
+ #
438
+ def encode arg=[], &block
439
+ input, output = separate_input arg, [:audio], [], &block
440
+ output = Array(output) unless output.kind_of? Array
441
+
442
+ if (output & [:lossless, :lossy, :everything, :nonaudio, :audio]) != []
443
+ raise "Target specifier symbols are not valid for output."
444
+ end
445
+ raise "No output format specified." if !block && output.empty?
446
+
447
+ unless block
448
+ sym = []; int = []; str = []; other = 0
449
+ output.each do |x|
450
+ if x.kind_of? Symbol
451
+ sym << x
452
+ elsif x.kind_of? Numeric
453
+ int << x
454
+ elsif x.kind_of? String
455
+ str << x
456
+ else
457
+ other += 1
458
+ end
459
+ end
460
+ end
461
+
462
+ if [:everything, :nonaudio] & input != []
463
+ unless ([:copy, :skip] & output != []) || block
464
+ raise "only valid targets for :everything and :nonaudio are :copy and :skip"
465
+ end
466
+ raise "Bitrate not allowed with :everything and :nonaudio." if int.size > 0
467
+ end
468
+
469
+ unless block ||
470
+ (sym.size == 1 && int.empty? && str.empty? && other == 0) ||
471
+ (str.size + int.size >= 1 && str.size < 2 && int.size < 2 && other == 0 && sym.empty?)
472
+ raise "Output format incorrectly specified."
473
+ end
474
+
475
+ output = if ! sym.empty?
476
+ [sym[0]]
477
+ else
478
+ int.empty? ? [str[0]] : [str[0], int[0]]
479
+ end
480
+
481
+ input = InputRules.new(input, &block)
482
+ @encode_rules.delete_if { |x| x[0] == input }
483
+ @encode_rules << [input, output]
484
+ end
485
+
486
+ # An alias to encode using :skip as the action.
487
+ #
488
+ # gencode
489
+ #
490
+ def skip arg=[]
491
+ encode(arg=>:skip)
492
+ end
493
+
494
+ # An alias to encode using :copy as the action.
495
+ #
496
+ #
497
+ def copy arg=[]
498
+ encode(arg=>:copy)
499
+ end
500
+
501
+ # Currently not implemented.
502
+ #
503
+ # Set a rule for what path to use for the output of a matched
504
+ # file. See the common documentation for Generator for input
505
+ # format.
506
+ #
507
+ def path arg=[], &block
508
+ # TODO: implement configurable paths
509
+ input, output = separate_input arg, [:audio], '', &block
510
+ raise "not implemented yet" unless output==:original
511
+ raise "No output path specified." if !block && output == ''
512
+ raise "Output path incorrectly specified." unless block || output == :original || output.kind_of?(String)
513
+ if [:everything, :nonaudio] & input != []
514
+ raise unless (output == :original) || block
515
+ end
516
+
517
+ input = InputRules.new(input, &block)
518
+ @path_rules.delete_if { |x| x[0] == input }
519
+ @path_rules << [input, output]
520
+ end
521
+
522
+ # Set a rule for what option string to pass to sox for matched
523
+ # files. See the common documentation for Generator for input
524
+ # format. See man page for sox for available options.
525
+ #
526
+ def options arg=[], &block
527
+ input, output = separate_input arg, [:audio], '', &block
528
+ raise "Output path incorrectly specified." unless block || output.kind_of?(String)
529
+ raise if [:everything, :nonaudio] & input != []
530
+
531
+ input = InputRules.new(input, &block)
532
+ @option_rules.delete_if { |x| x[0] == input }
533
+ @option_rules << [input, output]
534
+ end
535
+
536
+ # Set a rule for what effect option string to pass to sox for
537
+ # matched files. See the common documentation for Generator for
538
+ # input format. See man page for sox for available options.
539
+ #
540
+ def effect_options arg=[], &block
541
+ input, output = separate_input arg, [:audio], '', &block
542
+ raise "Effect incorrectly specified." unless block || output.kind_of?(String)
543
+ raise if [:everything, :nonaudio] & input != []
544
+
545
+ input = InputRules.new(input, &block)
546
+ @effect_option_rules.delete_if { |x| x[0] == input }
547
+ @effect_option_rules << [input, output]
548
+ end
549
+
550
+ # Set a rule for whether to write over an existing file. The rule
551
+ # is matched against the *input* filename, not the output. See the
552
+ # common documentation for Generator for input format.
553
+ #
554
+ # The action part of the input can be True or False, the symbol
555
+ # :rename, or a string. If the action for a matched file is True,
556
+ # any existing file will be overwritten. If False, the file will
557
+ # be silently skipped. If :rename, a numbered suffix will be
558
+ # appended if necessary to create a unique name. If a string is
559
+ # given, that string will be appended to the filename (before the
560
+ # extension) if necessary to avoid writing over a file; however
561
+ # anything with that new filename will be silently overwritten.
562
+ #
563
+ def clobber arg=[], &block
564
+ input, output = separate_input arg, [:everything], true, &block
565
+ raise "Effect incorrectly specified." unless block ||
566
+ [String, NilClass, TrueClass, FalseClass].include?(output.class) || output == :rename
567
+
568
+ input = InputRules.new(input, &block)
569
+ @clobber_rules.delete_if { |x| x[0] == input }
570
+ @clobber_rules << [input, output]
571
+ end
572
+
573
+ # Set a rule for whether to remove a file from the target
574
+ # directory if there was no file in the source directory or
575
+ # playlist to generate it. The rule is matched against the *
576
+ # filename, not the output. See the common documentation for
577
+ # Generator for input format. Action values should be True or
578
+ # False.
579
+ #
580
+ def remove_missing arg=[], &block
581
+ input, output = separate_input arg, [:everything], true, &block
582
+ raise "Effect incorrectly specified." unless block ||
583
+ [NilClass, TrueClass, FalseClass].include?(output.class)
584
+
585
+ input = InputRules.new(input, &block)
586
+ @remove_missing_rules.delete_if { |x| x[0] == input }
587
+ @remove_missing_rules << [input, output]
588
+ end
589
+ end
590
+
591
+ # Create a new Generator instance, yield it to the block (or call
592
+ # instance_eval, if arity==0), and then call generate on the instance.
593
+ #
594
+ def self.generate *args, &block
595
+ g = Generator.new(&block)
596
+ g.generate(*args)
597
+ end
598
+ end
599
+
600
+ # tp = ThreadPool.new(2)
601
+
602
+ # int_level = 0
603
+ # trap("INT") do
604
+ # if int_level == 0
605
+ # int_level += 1
606
+ # puts "Will stop after current processes are finished; press CTRL-C again to stop immediately."
607
+ # tp.stop
608
+ # abort
609
+ # else
610
+ # tp.kill
611
+ # end
612
+ # end
613
+
614
+ # tp.join