musicbox 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,59 @@
1
+ class MusicBox
2
+
3
+ class Catalog
4
+
5
+ class ReleaseArtist
6
+
7
+ attr_accessor :active
8
+ attr_accessor :anv
9
+ attr_accessor :id
10
+ attr_accessor :join
11
+ attr_accessor :name
12
+ attr_accessor :resource_url
13
+ attr_accessor :role
14
+ attr_accessor :thumbnail_url
15
+ attr_accessor :tracks
16
+ attr_accessor :artist # linked on load
17
+
18
+ def self.artists_to_s(artists)
19
+ artists.map do |artist|
20
+ artist.name + ((artist.join == ',') ? artist.join : (' ' + artist.join))
21
+ end.flatten.join(' ').squeeze(' ').strip
22
+ end
23
+
24
+ def initialize(params={})
25
+ params.each { |k, v| send("#{k}=", v) }
26
+ end
27
+
28
+ def to_s
29
+ @name
30
+ end
31
+
32
+ def canonical_name
33
+ name = (@@canonical_names[@name] || @name).sub(/\s\(\d+\)/, '') # handle 'Nico (3)'
34
+ if @@personal_names.include?(name)
35
+ elems = name.split(/\s+/)
36
+ [elems[-1], elems[0..-2].join(' ')].join(', ')
37
+ else
38
+ name
39
+ end
40
+ end
41
+
42
+ def key
43
+ key = ''
44
+ tokens = canonical_name.tokenize
45
+ while (token = tokens.shift) && key.length < 4
46
+ if key.empty?
47
+ key << token[0..2].capitalize
48
+ else
49
+ key << token[0].upcase
50
+ end
51
+ end
52
+ key
53
+ end
54
+
55
+ end
56
+
57
+ end
58
+
59
+ end
@@ -0,0 +1,15 @@
1
+ class MusicBox
2
+
3
+ class Catalog
4
+
5
+ class Releases < Group
6
+
7
+ def self.item_class
8
+ Release
9
+ end
10
+
11
+ end
12
+
13
+ end
14
+
15
+ end
@@ -0,0 +1,116 @@
1
+ class MusicBox
2
+
3
+ class Catalog
4
+
5
+ class Tags
6
+
7
+ attr_accessor :current
8
+ attr_accessor :changes
9
+
10
+ TagFlags = {
11
+ album: 'A', # album title
12
+ title: 's', # track title
13
+ artist: 'a', # artist name
14
+ album_artist: 'R', # album artist name
15
+ composer: 'w', # composer
16
+ grouping: 'G', # grouping
17
+ disc: 'd', # disc number
18
+ discs: 'D', # total discs
19
+ track: 't', # track number
20
+ tracks: 'T', # total tracks
21
+ year: 'y', # release year
22
+ }
23
+
24
+ def self.load(file)
25
+ new.tap { |t| t.load(file) }
26
+ end
27
+
28
+ def initialize(params={})
29
+ @current = {}
30
+ @changes = {}
31
+ params.each { |k, v| send("#{k}=", v) }
32
+ end
33
+
34
+ def [](key)
35
+ @changes.has_key?(key) ? @changes[key] : @current[key]
36
+ end
37
+
38
+ def []=(key, value)
39
+ raise unless TagFlags[key]
40
+ @changes[key] = value unless @current[key] == value
41
+ end
42
+
43
+ def changed?
44
+ !@changes.empty?
45
+ end
46
+
47
+ def update(hash)
48
+ hash.each { |k, v| self[k] = v }
49
+ end
50
+
51
+ def load(file)
52
+ cmd = [
53
+ 'ffprobe',
54
+ '-loglevel', 'error',
55
+ '-show_entries', 'format',
56
+ '-i', file,
57
+ ].map(&:to_s)
58
+ IO.popen(cmd, 'r') do |pipe|
59
+ pipe.readlines.map(&:strip).each do |line|
60
+ if line =~ /^TAG:(.*?)=(.*?)$/
61
+ key, value = $1.to_sym, $2.strip
62
+ info = case key
63
+ when :date
64
+ { year: value.to_i }
65
+ when :track
66
+ track, tracks = value.split('/').map(&:to_i)
67
+ {
68
+ track: track,
69
+ tracks: tracks,
70
+ }
71
+ when :disc
72
+ disc, discs = value.split('/').map(&:to_i)
73
+ {
74
+ disc: disc,
75
+ discs: discs,
76
+ }
77
+ else
78
+ if TagFlags[key]
79
+ { key => value }
80
+ else
81
+ {}
82
+ end
83
+ end
84
+ @current.update(info)
85
+ end
86
+ end
87
+ end
88
+ @current[:track] ||= file.basename.to_s.to_i
89
+ end
90
+
91
+ def save(file, force: false)
92
+ return unless changed? || force
93
+ set_flags = {}
94
+ remove_flags = []
95
+ @current.merge(@changes).each do |key, value|
96
+ flag = TagFlags[key] or raise
97
+ if value
98
+ set_flags[flag] = value
99
+ else
100
+ remove_flags << flag
101
+ end
102
+ end
103
+ run_command('mp4tags',
104
+ set_flags.map { |c, v| ["-#{c}", v] },
105
+ !remove_flags.empty? ? ['-remove', remove_flags.join] : nil,
106
+ file)
107
+ run_command('mdimport',
108
+ '-i',
109
+ file)
110
+ end
111
+
112
+ end
113
+
114
+ end
115
+
116
+ end
@@ -0,0 +1,43 @@
1
+ class MusicBox
2
+
3
+ class Catalog
4
+
5
+ class Track
6
+
7
+ attr_accessor :type
8
+ attr_accessor :position
9
+ attr_accessor :title
10
+ attr_accessor :duration
11
+ attr_accessor :extraartists
12
+ attr_accessor :artists
13
+ attr_accessor :sub_tracks
14
+
15
+ def initialize(params={})
16
+ params.each { |k, v| send("#{k}=", v) }
17
+ end
18
+
19
+ def type_=(type)
20
+ self.type = type
21
+ end
22
+
23
+ def artists=(artists)
24
+ @artists = artists.map { |a| ReleaseArtist.new(a) }
25
+ end
26
+
27
+ def extraartists=(artists)
28
+ @extraartists = artists.map { |a| ReleaseArtist.new(a) }
29
+ end
30
+
31
+ def sub_tracks=(sub_tracks)
32
+ @sub_tracks = sub_tracks.map { |t| Track.new(t) }
33
+ end
34
+
35
+ def artist
36
+ @artists ? ReleaseArtist.artists_to_s(@artists) : nil
37
+ end
38
+
39
+ end
40
+
41
+ end
42
+
43
+ end
@@ -0,0 +1,49 @@
1
+ class MusicBox
2
+
3
+ class CoverMaker
4
+
5
+ def self.make_covers(*releases, output_file:)
6
+ cover_maker = new
7
+ cover_maker.make_covers(releases)
8
+ cover_maker.write(output_file)
9
+ end
10
+
11
+ def initialize
12
+ @pdf = Prawn::Document.new
13
+ end
14
+
15
+ def make_covers(releases)
16
+ size = 4.75.in
17
+ top = 10.in
18
+ releases.each_with_index do |release, i|
19
+ album = release.album
20
+ unless album&.has_cover?
21
+ puts "Release #{release.id} has no cover"
22
+ next
23
+ end
24
+ @pdf.start_new_page if i > 0
25
+ @pdf.fill do
26
+ @pdf.rectangle [0, top],
27
+ size,
28
+ size
29
+ end
30
+ @pdf.image album.cover_file.to_s,
31
+ at: [0, top],
32
+ width: size,
33
+ fit: [size, size],
34
+ position: :center
35
+ @pdf.stroke do
36
+ @pdf.rectangle [0, top],
37
+ size,
38
+ size
39
+ end
40
+ end
41
+ end
42
+
43
+ def write(output_file)
44
+ @pdf.render_file(output_file.to_s)
45
+ end
46
+
47
+ end
48
+
49
+ end
@@ -0,0 +1,53 @@
1
+ class MusicBox
2
+
3
+ class Discogs
4
+
5
+ AppName = 'musicbox-discogs'
6
+ ResultsPerPage = 100
7
+
8
+ def initialize(catalog:)
9
+ @catalog = catalog
10
+ @user, @token = @catalog.config.values_at('user', 'token')
11
+ @discogs = ::Discogs::Wrapper.new(AppName, user_token: @token)
12
+ end
13
+
14
+ def update
15
+ @catalog.collection.destroy!
16
+ page = 1
17
+ loop do
18
+ result = discogs_do(:get_user_collection, @user, page: page, per_page: ResultsPerPage)
19
+ result.releases.each do |release|
20
+ begin
21
+ update_release(release)
22
+ rescue Error => e
23
+ warn "Error: #{e}"
24
+ end
25
+ end
26
+ page = result.pagination.page + 1
27
+ break if page > result.pagination.pages
28
+ end
29
+ end
30
+
31
+ def update_release(release)
32
+ @catalog.collection.save_item(id: release.id, item: release)
33
+ info = release.basic_information
34
+ @catalog.releases.save_item_if_new(id: info.id) { discogs_do(:get_release, info.id) }
35
+ if info.master_id && info.master_id > 0
36
+ @catalog.masters.save_item_if_new(id: info.master_id) { discogs_do(:get_master_release, info.master_id) }
37
+ end
38
+ info.artists.each do |artist|
39
+ @catalog.artists.save_item_if_new(id: artist.id) { discogs_do(:get_artist, artist.id) }
40
+ end
41
+ end
42
+
43
+ def discogs_do(command, *args)
44
+ sleep(1)
45
+ ;;pp(command: command, args: args)
46
+ result = @discogs.send(command, *args)
47
+ raise Error, "Bad result: #{result.inspect}" if result.nil? || result.message
48
+ result
49
+ end
50
+
51
+ end
52
+
53
+ end
@@ -0,0 +1,86 @@
1
+ class MusicBox
2
+
3
+ # https://ffmpeg.org/ffmpeg-filters.html
4
+ # https://github.com/jaakkopasanen/AutoEq
5
+
6
+ class Equalizer
7
+
8
+ attr_accessor :name
9
+ attr_accessor :volume_filter
10
+ attr_accessor :equalizer_filters
11
+
12
+ include SetParams
13
+
14
+ def self.load_equalizers(dir:, name:)
15
+ dir.glob("**/*#{name}*/*ParametricEQ.txt").map do |file|
16
+ name = '%s (%s)' % [file.dirname.basename, file.dirname.dirname.basename]
17
+ new(name: name).tap { |e| e.load(file) }
18
+ end.sort
19
+ end
20
+
21
+ def initialize(params={})
22
+ @equalizer_filters = []
23
+ set(params)
24
+ end
25
+
26
+ def load(file)
27
+ file.readlines.map { |l| l.sub(/#.*/, '') }.map(&:strip).reject(&:empty?).each do |line|
28
+ key, value = line.split(/:\s+/, 1)
29
+ case key
30
+ when /^Preamp: ([-.\d]+) dB$/
31
+ @volume_filter = VolumeFilter.new(volume: $1.to_f)
32
+ when /^Filter \d+: ON PK Fc (\d+) Hz Gain ([-.\d]+) dB Q ([-.\d]+)$/
33
+ @equalizer_filters << EqualizerFilter.new(
34
+ frequency: $1.to_i,
35
+ gain: $2.to_f,
36
+ width: $3.to_f,
37
+ type: 'q')
38
+ else
39
+ warn "Ignoring eq line: #{line.inspect}"
40
+ end
41
+ end
42
+ end
43
+
44
+ def <=>(other)
45
+ @name <=> other.name
46
+ end
47
+
48
+ def to_s(enabled: true)
49
+ [@volume_filter, enabled ? @equalizer_filters : nil].flatten.compact.join(',')
50
+ end
51
+
52
+ class VolumeFilter
53
+
54
+ attr_accessor :volume
55
+
56
+ include SetParams
57
+
58
+ def to_s
59
+ "volume=#{@volume}dB"
60
+ end
61
+
62
+ end
63
+
64
+ class EqualizerFilter
65
+
66
+ attr_accessor :frequency
67
+ attr_accessor :gain
68
+ attr_accessor :width
69
+ attr_accessor :type
70
+
71
+ include SetParams
72
+
73
+ def to_s
74
+ "equalizer=%s" % {
75
+ f: @frequency,
76
+ g: @gain,
77
+ w: @width,
78
+ t: @type,
79
+ }.map { |kv| kv.join('=') }.join(':')
80
+ end
81
+
82
+ end
83
+
84
+ end
85
+
86
+ end
@@ -0,0 +1,5 @@
1
+ class MusicBox
2
+
3
+ class Error < Exception; end
4
+
5
+ end
@@ -0,0 +1,76 @@
1
+ class MusicBox
2
+
3
+ class Exporter
4
+
5
+ def initialize(catalog:, dir:, compress: false, force: false, parallel: false)
6
+ @catalog = catalog
7
+ @dir = Path.new(dir).expand_path
8
+ @compress = compress
9
+ @force = force
10
+ @parallel = parallel
11
+ end
12
+
13
+ def export_album(album)
14
+ raise Error, "Must specify destination directory" unless @dir
15
+ name = '%s - %s (%s)' % [album.artist, album.title, album.year]
16
+ export_dir = @dir / name
17
+ export_dir.mkpath unless export_dir.exist?
18
+ threads = []
19
+ album.tracks.each do |track|
20
+ src_file = track.path
21
+ dst_file = export_dir / src_file.basename
22
+ if @force || !dst_file.exist? || dst_file.mtime != src_file.mtime
23
+ if @parallel
24
+ threads << Thread.new do
25
+ export_track(src_file, dst_file)
26
+ end
27
+ else
28
+ export_track(src_file, dst_file)
29
+ end
30
+ end
31
+ end
32
+ threads.map(&:join)
33
+ end
34
+
35
+ def export_track(src_file, dst_file)
36
+ if @compress
37
+ warn "compressing #{src_file}"
38
+ compress_track(src_file, dst_file)
39
+ else
40
+ warn "copying #{src_file}"
41
+ src_file.cp(dst_file)
42
+ end
43
+ end
44
+
45
+ def compress_track(src_file, dst_file)
46
+ begin
47
+ tags = Catalog::Tags.load(src_file)
48
+ caf_file = dst_file.replace_extension('.caf')
49
+ run_command('afconvert',
50
+ src_file,
51
+ caf_file,
52
+ '--data', 0,
53
+ '--file', 'caff',
54
+ '--soundcheck-generate')
55
+ run_command('afconvert',
56
+ caf_file,
57
+ '--data', 'aac',
58
+ '--file', 'm4af',
59
+ '--soundcheck-read',
60
+ '--bitrate', 256000,
61
+ '--quality', 127,
62
+ '--strategy', 2,
63
+ dst_file)
64
+ tags.save(dst_file, force: true)
65
+ dst_file.utime(src_file.atime, src_file.mtime)
66
+ rescue => e
67
+ dst_file.unlink if dst_file.exist?
68
+ raise e
69
+ ensure
70
+ caf_file.unlink if caf_file.exist?
71
+ end
72
+ end
73
+
74
+ end
75
+
76
+ end