musicbox 0.1

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,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