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,35 @@
1
+ class String
2
+
3
+ IgnoredWords = %w{
4
+ a
5
+ an
6
+ and
7
+ but
8
+ de
9
+ des
10
+ for
11
+ from
12
+ le
13
+ les
14
+ of
15
+ on
16
+ or
17
+ the
18
+ to
19
+ }
20
+
21
+ def normalize
22
+ downcase. # lowercase
23
+ unaccent. # 'normalize' accents
24
+ delete(%q{'"‘’“”}). # remove quotes
25
+ gsub(/[^a-z0-9]+/, ' '). # convert non-alphanumeric to whitespace
26
+ strip.squeeze(' ') # compress/remove whitespace
27
+ end
28
+
29
+ def tokenize
30
+ words = normalize.split(/\s+/)
31
+ new_words = words - IgnoredWords
32
+ new_words.empty? ? words : new_words # handles 'The The', etc.
33
+ end
34
+
35
+ end
data/lib/musicbox.rb ADDED
@@ -0,0 +1,268 @@
1
+ require 'csv'
2
+ require 'base64'
3
+ require 'discogs-wrapper'
4
+ require 'http'
5
+ require 'io-dispatcher'
6
+ require 'json'
7
+ require 'mpv_client'
8
+ require 'path'
9
+ require 'prawn'
10
+ require 'prawn/measurement_extensions'
11
+ require 'run-command'
12
+ require 'set'
13
+ require 'set_params'
14
+ require 'sixarm_ruby_unaccent'
15
+ require 'tty-prompt'
16
+ require 'yaml'
17
+
18
+ require 'extensions/string'
19
+
20
+ require 'musicbox/version'
21
+ require 'musicbox/error'
22
+ require 'musicbox/group'
23
+ require 'musicbox/info_to_s'
24
+
25
+ require 'musicbox/catalog'
26
+ require 'musicbox/catalog/album'
27
+ require 'musicbox/catalog/album_track'
28
+ require 'musicbox/catalog/albums'
29
+ require 'musicbox/catalog/artist'
30
+ require 'musicbox/catalog/artists'
31
+ require 'musicbox/catalog/format'
32
+ require 'musicbox/catalog/collection_item'
33
+ require 'musicbox/catalog/collection'
34
+ require 'musicbox/catalog/release'
35
+ require 'musicbox/catalog/release_artist'
36
+ require 'musicbox/catalog/releases'
37
+ require 'musicbox/catalog/tags'
38
+ require 'musicbox/catalog/track'
39
+
40
+ require 'musicbox/cover_maker'
41
+ require 'musicbox/discogs'
42
+ require 'musicbox/equalizer'
43
+ require 'musicbox/exporter'
44
+ require 'musicbox/extractor'
45
+ require 'musicbox/importer'
46
+ require 'musicbox/label_maker'
47
+ require 'musicbox/player'
48
+
49
+ class MusicBox
50
+
51
+ attr_accessor :catalog
52
+
53
+ def initialize(root:)
54
+ @catalog = Catalog.new(root: root)
55
+ @prompt = TTY::Prompt.new
56
+ end
57
+
58
+ def export(args, **params)
59
+ exporter = Exporter.new(catalog: @catalog, **params)
60
+ @catalog.find(args, group: :releases).each do |release|
61
+ album = release.album or raise Error, "Album does not exist for release #{release.id}"
62
+ exporter.export_album(album)
63
+ end
64
+ end
65
+
66
+ def extract(args)
67
+ extractor = Extractor.new(catalog: @catalog)
68
+ @catalog.dirs_for_args(@catalog.extract_dir, args).each do |dir|
69
+ extractor.extract_dir(dir)
70
+ end
71
+ end
72
+
73
+ def fix(args)
74
+ # key_map = {
75
+ # :title => :title,
76
+ # :artist => :artist,
77
+ # :original_release_year => :year,
78
+ # :format_quantity => :discs,
79
+ # }
80
+ # find(args, group: :releases).select(&:cd?).each do |release|
81
+ # diffs = {}
82
+ # key_map.each do |release_key, album_key|
83
+ # release_value = release.send(release_key)
84
+ # album_value = release.album.send(album_key)
85
+ # if album_value && release_value != album_value
86
+ # diffs[release_key] = [release_value, album_value]
87
+ # end
88
+ # end
89
+ # unless diffs.empty?
90
+ # puts release
91
+ # diffs.each do |key, values|
92
+ # puts "\t" + '%s: %p => %p' % [key, *values]
93
+ # end
94
+ # puts
95
+ # end
96
+ # end
97
+ end
98
+
99
+ def formats(args)
100
+ formats = {}
101
+ @catalog.find(args, group: :releases).each do |release|
102
+ release.formats.each do |format|
103
+ formats[format.name] ||= 0
104
+ formats[format.name] += 1
105
+ end
106
+ end
107
+ formats.each do |name, count|
108
+ puts '%5d %s' % [count, name]
109
+ end
110
+ end
111
+
112
+ def extract_cover(args)
113
+ @catalog.find(args, group: :releases).select(&:album).each do |release|
114
+ release.album.extract_cover
115
+ end
116
+ end
117
+
118
+ def download_images(args)
119
+ @catalog.find(args, group: :releases).select(&:cd?).each do |release|
120
+ release.download_images
121
+ end
122
+ end
123
+
124
+ def cover(args, prompt: false, output_file: '/tmp/cover.pdf')
125
+ releases = []
126
+ @catalog.find(args, group: :releases, prompt: prompt).select(&:album).each do |release|
127
+ release.select_cover unless release.album.has_cover?
128
+ releases << release if release.album.has_cover?
129
+ end
130
+ CoverMaker.make_covers(*releases, output_file: output_file)
131
+ run_command('open', output_file)
132
+ end
133
+
134
+ def select_cover(args, prompt: false)
135
+ @catalog.find(args, group: :releases, prompt: prompt).select(&:album).each do |release|
136
+ release.select_cover
137
+ end
138
+ end
139
+
140
+ def import(args)
141
+ importer = Importer.new(catalog: @catalog)
142
+ @catalog.dirs_for_args(@catalog.import_dir, args).each do |dir|
143
+ begin
144
+ importer.import_dir(dir)
145
+ rescue Error => e
146
+ warn "Error: #{e}"
147
+ end
148
+ end
149
+ end
150
+
151
+ def label(args)
152
+ labels = @catalog.find(args, group: :releases, prompt: true).map(&:to_label)
153
+ output_file = '/tmp/labels.pdf'
154
+ label_maker = LabelMaker.new
155
+ label_maker.make_labels(labels)
156
+ label_maker.write(output_file)
157
+ run_command('open', output_file)
158
+ end
159
+
160
+ def dir(args, group: nil)
161
+ @catalog.find(args, group: group).each do |release|
162
+ puts "%-10s %s" % [release.id, release.dir]
163
+ end
164
+ end
165
+
166
+ def open(args, group: nil)
167
+ @catalog.find(args, group: group).each do |release|
168
+ run_command('open', release.dir)
169
+ end
170
+ end
171
+
172
+ def orphaned
173
+ @catalog.orphaned.each do |group, items|
174
+ unless items.empty?
175
+ puts "#{group}:"
176
+ items.sort.each do |item|
177
+ puts item
178
+ end
179
+ puts
180
+ end
181
+ end
182
+ images = @catalog.orphaned_images
183
+ unless images.empty?
184
+ puts "Images:"
185
+ images.sort.each do |image|
186
+ puts "\t" + image.to_s
187
+ end
188
+ puts
189
+ end
190
+ end
191
+
192
+ def show(args, group: nil, mode: :summary, prompt: false)
193
+ @catalog.find(args, group: group, prompt: prompt).each do |release|
194
+ case mode
195
+ when :cover
196
+ release.album.show_cover if release.album&.has_cover?
197
+ when :details
198
+ puts release.details_to_s
199
+ puts
200
+ when :summary
201
+ puts release
202
+ end
203
+ end
204
+ end
205
+
206
+ def csv(args)
207
+ print Catalog::Release.csv_header
208
+ @catalog.find(args, group: :releases).each do |release|
209
+ print release.to_csv
210
+ end
211
+ end
212
+
213
+ def dups(args)
214
+ dups = @catalog.find_dups(@catalog.find(args, group: :releases))
215
+ dups.each do |id, formats|
216
+ formats.each do |format, releases|
217
+ if releases.length > 1
218
+ puts
219
+ releases.each { |r| puts r }
220
+ end
221
+ end
222
+ end
223
+ end
224
+
225
+ def artist_keys(args)
226
+ if args.empty?
227
+ args = @catalog.releases.items.map { |r| r.artists.map(&:name) }.flatten
228
+ end
229
+ ;;pp @catalog.artist_keys(args)
230
+ end
231
+
232
+ def play(args, prompt: false, equalizer_name: nil, **params)
233
+ albums = @catalog.find(args, prompt: prompt).map(&:album).compact
234
+ if equalizer_name
235
+ equalizers = Equalizer.load_equalizers(
236
+ dir: Path.new(@catalog.config['equalizers_dir']),
237
+ name: equalizer_name)
238
+ else
239
+ equalizers = nil
240
+ end
241
+ player = MusicBox::Player.new(
242
+ albums: albums,
243
+ equalizers: equalizers,
244
+ **params)
245
+ player.play
246
+ end
247
+
248
+ def select(args)
249
+ ids = []
250
+ loop do
251
+ releases = @catalog.find(args, group: :releases, prompt: true) or break
252
+ ids += releases.map(&:id)
253
+ puts ids.join(' ')
254
+ end
255
+ end
256
+
257
+ def update
258
+ Discogs.new(catalog: @catalog).update
259
+ end
260
+
261
+ def update_tags(args, force: false)
262
+ @catalog.find(args, group: :releases).select(&:album).each do |release|
263
+ puts release
264
+ release.album.update_tags(force: force)
265
+ end
266
+ end
267
+
268
+ end
@@ -0,0 +1,205 @@
1
+ class MusicBox
2
+
3
+ class Catalog
4
+
5
+ attr_accessor :root_dir
6
+ attr_accessor :import_dir
7
+ attr_accessor :import_done_dir
8
+ attr_accessor :extract_dir
9
+ attr_accessor :extract_done_dir
10
+ attr_accessor :catalog_dir
11
+ attr_accessor :images_dir
12
+ attr_accessor :config
13
+ attr_accessor :collection
14
+ attr_accessor :releases
15
+ attr_accessor :masters
16
+ attr_accessor :artists
17
+ attr_accessor :albums
18
+ attr_accessor :groups
19
+
20
+ def initialize(root: nil)
21
+ @root_dir = Path.new(root || ENV['MUSICBOX_ROOT'] || '~/Music/MusicBox').expand_path
22
+ raise Error, "#{@root_dir} doesn't exist" unless @root_dir.exist?
23
+ load_config
24
+ @import_dir = @root_dir / 'import'
25
+ @import_done_dir = @root_dir / 'import-done'
26
+ @extract_dir = @root_dir / 'extract'
27
+ @extract_done_dir = @root_dir / 'extract-done'
28
+ @catalog_dir = @root_dir / 'catalog'
29
+ @collection = Collection.new(root: @catalog_dir / 'collection')
30
+ @releases = Releases.new(root: @catalog_dir / 'releases')
31
+ @masters = Releases.new(root: @catalog_dir / 'masters')
32
+ @artists = Artists.new(root: @catalog_dir / 'artists')
33
+ @albums = Albums.new(root: @catalog_dir / 'albums')
34
+ @images_dir = @catalog_dir / 'images'
35
+ link_groups
36
+ @prompt = TTY::Prompt.new
37
+ end
38
+
39
+ def load_config
40
+ @config = YAML.load((@root_dir / 'config.yaml').read)
41
+ ReleaseArtist.class_variable_set(:@@personal_names, @config['personal_names'])
42
+ ReleaseArtist.class_variable_set(:@@canonical_names, @config['canonical_names'])
43
+ end
44
+
45
+ def orphaned
46
+ orphaned = %i[releases masters artists albums].map { |k| [k, send(k).items.dup] }.to_h
47
+ @collection.items.each do |item|
48
+ release = item.release or raise
49
+ orphaned[:releases].delete(release)
50
+ orphaned[:masters].delete(release.master) if release.master
51
+ release.artists.each do |release_artist|
52
+ orphaned[:artists].delete(release_artist.artist)
53
+ end
54
+ orphaned[:albums].delete(release.album) if release.album
55
+ end
56
+ orphaned
57
+ end
58
+
59
+ def orphaned_images
60
+ all_files = [@releases, @masters].map do |group|
61
+ group.items.select(&:images).map do |release|
62
+ release.images.map { |image| image['file'].basename.to_s }
63
+ end
64
+ end.flatten.compact
65
+ @images_dir.children.map(&:basename).map(&:to_s) - all_files
66
+ end
67
+
68
+ def artist_keys(artists)
69
+ keys = {}
70
+ names = {}
71
+ non_personal_names = Set.new
72
+ artists.map { |a| a.kind_of?(ReleaseArtist) ? a : ReleaseArtist.new(name: a) }.each do |artist|
73
+ non_personal_names << artist.name if artist.name == artist.canonical_name
74
+ key = artist.key
75
+ (keys[key] ||= Set.new) << artist.name
76
+ (names[artist.name] ||= Set.new) << key
77
+ end
78
+ {
79
+ non_personal_names: non_personal_names.sort,
80
+ keys: keys.sort.map { |k, s| [k, s.to_a] }.to_h,
81
+ names: names.sort.map { |k, s| [k, s.to_a] }.to_h,
82
+ }
83
+ end
84
+
85
+ def find_dups(releases)
86
+ dups = {}
87
+ releases.select(&:master_id).each do |release|
88
+ dups[release.master_id] ||= {}
89
+ #FIXME: wrong
90
+ release.formats.map(&:name).each do |format_name|
91
+ dups[release.master_id][format_name] ||= []
92
+ dups[release.master_id][format_name] << release
93
+ end
94
+ end
95
+ dups
96
+ end
97
+
98
+ def find(*selectors, group: nil, prompt: false, multiple: true)
99
+ unless group.kind_of?(Group)
100
+ group = case group&.to_sym
101
+ when :releases, nil
102
+ @releases
103
+ when :masters
104
+ @masters
105
+ when :albums
106
+ @albums
107
+ else
108
+ raise Error, "Unknown group: #{group.inspect}"
109
+ end
110
+ end
111
+ ;;puts "searching #{group.items.count} items in #{group.class}"
112
+ selectors = [selectors].compact.flatten
113
+ selectors = [':all'] if selectors.empty?
114
+ selected = []
115
+ selectors.each do |selector|
116
+ case selector.to_s
117
+ when ':all'
118
+ selected += group.items
119
+ when ':recent'
120
+ selected += group.items.select { |c| (Date.today - c.date_added) < 7 }
121
+ when ':recently-added'
122
+ selected += @collection.items.select { |c| (Date.today - c.date_added) < 30 }.map(&:release)
123
+ when ':multiformat'
124
+ selected += group.items.select { |r| r.formats&.length > 1 }
125
+ when ':cd'
126
+ selected += group.items.select(&:cd?)
127
+ when ':unripped'
128
+ selected += group.items.select(&:cd?).reject(&:album)
129
+ when ':no-cover'
130
+ selected += group.items.select(&:album).reject { |r| r.album.has_cover? }
131
+ when ':odd-positions'
132
+ selected += group.items.select(&:cd?).select { |r| r.tracklist_flattened.find { |t| t.position !~ /^\d+$/ } }
133
+ when /^-?\d+$/
134
+ n = selector.to_i
135
+ item = group[n.abs] or raise Error, "Can't find item #{selector.inspect} in #{group.class}"
136
+ if n > 0
137
+ selected += [group[n]]
138
+ else
139
+ selected -= [group[-n]]
140
+ end
141
+ else
142
+ selected += group.search(query: selector.to_s, fields: [:title, :artists, :id])
143
+ end
144
+ end
145
+ selected.uniq.sort!
146
+ if prompt
147
+ choices = selected.map { |r| [r.to_s, r.id] }.to_h
148
+ if multiple
149
+ ids = @prompt.multi_select('Item?', filter: true, per_page: 100, quiet: true) do |menu|
150
+ # menu.default *(1..choices.length).to_a
151
+ choices.each do |name, value|
152
+ menu.choice name, value
153
+ end
154
+ end
155
+ selected = ids.map { |id| group[id] }
156
+ else
157
+ id = @prompt.select('Item?', choices, filter: true, per_page: 100, quiet: true)
158
+ selected = [group[id]] if id
159
+ end
160
+ end
161
+ selected
162
+ end
163
+
164
+ def link_groups
165
+ @releases.items.each do |release|
166
+ release.master = @masters[release.master_id] if release.master_id
167
+ release.artists.each do |release_artist|
168
+ release_artist.artist = @artists[release_artist.id]
169
+ end
170
+ release.album = @albums[release.id]
171
+ release.link_images(@images_dir)
172
+ release.master&.link_images(@images_dir)
173
+ end
174
+ @collection.items.each do |item|
175
+ item.release = @releases[item.id]
176
+ end
177
+ end
178
+
179
+ def categorize_files(dir)
180
+ categories = {}
181
+ dir.children.sort.each do |path|
182
+ type = case (ext = path.extname.downcase)
183
+ when '.m4a', '.m4p', '.mp3'
184
+ :audio
185
+ else
186
+ ext.delete_prefix('.').to_sym
187
+ end
188
+ categories[type] ||= []
189
+ categories[type] << path
190
+ end
191
+ categories
192
+ end
193
+
194
+ def dirs_for_args(base_dir, args)
195
+ if args.empty?
196
+ dirs = base_dir.children.select(&:dir?)
197
+ else
198
+ dirs = args.map { |p| Path.new(p) }
199
+ end
200
+ dirs.sort_by { |d| d.to_s.downcase }
201
+ end
202
+
203
+ end
204
+
205
+ end