musicbox 0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|