lossfully 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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