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.
- checksums.yaml +7 -0
- data/.gitignore +3 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/Rakefile +9 -0
- data/TODO.txt +145 -0
- data/bin/musicbox +118 -0
- data/lib/extensions/string.rb +35 -0
- data/lib/musicbox.rb +268 -0
- data/lib/musicbox/catalog.rb +205 -0
- data/lib/musicbox/catalog/album.rb +179 -0
- data/lib/musicbox/catalog/album_track.rb +66 -0
- data/lib/musicbox/catalog/albums.rb +19 -0
- data/lib/musicbox/catalog/artist.rb +52 -0
- data/lib/musicbox/catalog/artists.rb +15 -0
- data/lib/musicbox/catalog/collection.rb +15 -0
- data/lib/musicbox/catalog/collection_item.rb +35 -0
- data/lib/musicbox/catalog/format.rb +52 -0
- data/lib/musicbox/catalog/release.rb +243 -0
- data/lib/musicbox/catalog/release_artist.rb +59 -0
- data/lib/musicbox/catalog/releases.rb +15 -0
- data/lib/musicbox/catalog/tags.rb +116 -0
- data/lib/musicbox/catalog/track.rb +43 -0
- data/lib/musicbox/cover_maker.rb +49 -0
- data/lib/musicbox/discogs.rb +53 -0
- data/lib/musicbox/equalizer.rb +86 -0
- data/lib/musicbox/error.rb +5 -0
- data/lib/musicbox/exporter.rb +76 -0
- data/lib/musicbox/extractor.rb +35 -0
- data/lib/musicbox/group.rb +169 -0
- data/lib/musicbox/importer.rb +133 -0
- data/lib/musicbox/info_to_s.rb +18 -0
- data/lib/musicbox/label_maker.rb +61 -0
- data/lib/musicbox/player.rb +289 -0
- data/lib/musicbox/version.rb +5 -0
- data/musicbox.gemspec +38 -0
- metadata +303 -0
@@ -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,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,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
|