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 MusicBox
2
+
3
+ class Extractor
4
+
5
+ def initialize(catalog:)
6
+ @catalog = catalog
7
+ end
8
+
9
+ def extract_dir(source_dir)
10
+ puts "Extracting #{source_dir}"
11
+ files = @catalog.categorize_files(source_dir)
12
+ cue_file = files[:cue]&.shift or raise Error, "No cue file found"
13
+ bin_file = files[:audio]&.shift or raise Error, "No bin file found"
14
+ dest_dir = @catalog.import_dir / source_dir.basename
15
+ raise Error, "Extraction directory already exists: #{dest_dir}" if dest_dir.exist?
16
+ dest_dir.mkpath
17
+ xld_extract(cue_file: cue_file, bin_file: bin_file, dest_dir: dest_dir)
18
+ files.values.flatten.each { |p| p.cp_r(dest_dir) }
19
+ @catalog.extract_done_dir.mkpath unless @catalog.extract_done_dir.exist?
20
+ source_dir.rename(@catalog.extract_done_dir / source_dir.basename)
21
+ end
22
+
23
+ def xld_extract(cue_file:, bin_file:, dest_dir:)
24
+ dest_dir.chdir do
25
+ run_command('xld',
26
+ '-c', cue_file,
27
+ '-f', 'alac',
28
+ '--filename-format', '%D-%n - %t',
29
+ bin_file)
30
+ end
31
+ end
32
+
33
+ end
34
+
35
+ end
@@ -0,0 +1,169 @@
1
+ class MusicBox
2
+
3
+ class Group
4
+
5
+ attr_accessor :root
6
+
7
+ InfoFileName = 'info.json'
8
+
9
+ def self.item_class
10
+ Item
11
+ end
12
+
13
+ def initialize(root:)
14
+ @root = Path.new(root).expand_path
15
+ reset
16
+ load
17
+ end
18
+
19
+ def items
20
+ @items.values
21
+ end
22
+
23
+ def item_class
24
+ self.class.item_class
25
+ end
26
+
27
+ def reset
28
+ @items = {}
29
+ end
30
+
31
+ def load
32
+ reset
33
+ if @root.exist?
34
+ @root.glob("*/#{InfoFileName}").each do |info_file|
35
+ add_item(item_class.load(info_file.dirname))
36
+ end
37
+ ;;warn "* loaded #{@items.length} items from #{@root}"
38
+ end
39
+ end
40
+
41
+ def [](id)
42
+ @items[id]
43
+ end
44
+
45
+ def <<(item)
46
+ add_item(item)
47
+ end
48
+
49
+ def search(query:, fields:, limit: nil)
50
+ found = []
51
+ words = [query].flatten.join(' ').tokenize.sort.uniq - ['-']
52
+ words.each do |word|
53
+ regexp = Regexp.new(Regexp.quote(word), true)
54
+ found += @items.values.select do |item|
55
+ fields.find do |field|
56
+ case (value = item.send(field))
57
+ when Array
58
+ value.find { |v| v.to_s =~ regexp }
59
+ else
60
+ value.to_s =~ regexp
61
+ end
62
+ end
63
+ end
64
+ end
65
+ found = found.flatten.compact.uniq
66
+ found = found[0..limit - 1] if limit
67
+ found
68
+ end
69
+
70
+ def dir_for_id(id)
71
+ @root / id
72
+ end
73
+
74
+ def new_item(id, args={})
75
+ item = item_class.new(
76
+ {
77
+ id: id,
78
+ dir: dir_for_id(id),
79
+ }.merge(args)
80
+ )
81
+ add_item(item)
82
+ item
83
+ end
84
+
85
+ def add_item(item)
86
+ raise Error, "Item does not have ID" if item.id.nil?
87
+ raise Error, "Item already exists in #{@root}: #{item.id.inspect}" if @items[item.id]
88
+ @items[item.id] = item
89
+ end
90
+
91
+ def has_item?(id)
92
+ @items.has_key?(id)
93
+ end
94
+
95
+ def delete_item(item)
96
+ @items.delete(item.id)
97
+ end
98
+
99
+ def save_item(id:, item: nil, &block)
100
+ raise Error, "ID is nil" unless id
101
+ item = yield if block_given?
102
+ raise Error, "Item is nil" unless item
103
+ dir = dir_for_id(id)
104
+ info_file = dir / InfoFileName
105
+ dir.mkpath unless dir.exist?
106
+ ;;warn "writing to #{info_file}"
107
+ info_file.write(JSON.pretty_generate(item))
108
+ end
109
+
110
+ def save_item_if_new(id:, item: nil, &block)
111
+ unless has_item?(id)
112
+ save_item(id: id, item: item, &block)
113
+ end
114
+ end
115
+
116
+ def destroy_item(item)
117
+ dir = dir_for_id(id)
118
+ dir.rmtree if dir.exist?
119
+ delete_item(item)
120
+ end
121
+
122
+ def destroy!
123
+ @root.rmtree if @root.exist?
124
+ end
125
+
126
+ class Item
127
+
128
+ attr_accessor :id
129
+ attr_accessor :dir
130
+
131
+ def self.load(dir, params={})
132
+ dir = Path.new(dir)
133
+ info_file = dir / Group::InfoFileName
134
+ raise Error, "Info file does not exist: #{info_file}" unless info_file.exist?
135
+ new(JSON.load(info_file.read).merge(dir: dir).merge(params))
136
+ end
137
+
138
+ def initialize(params={})
139
+ params.each { |k, v| send("#{k}=", v) }
140
+ end
141
+
142
+ def info_file
143
+ @dir / Group::InfoFileName
144
+ end
145
+
146
+ def save
147
+ ;;warn "* saving item to #{@dir}"
148
+ raise Error, "dir not defined" unless @dir
149
+ @dir.mkpath unless @dir.exist?
150
+ info_file.write(JSON.pretty_generate(serialize))
151
+ end
152
+
153
+ def serialize(args={})
154
+ { id: @id }.merge(args).compact
155
+ end
156
+
157
+ def fields(keys)
158
+ keys.map { |k| send(k) }
159
+ end
160
+
161
+ def <=>(other)
162
+ @id <=> other.id
163
+ end
164
+
165
+ end
166
+
167
+ end
168
+
169
+ end
@@ -0,0 +1,133 @@
1
+ class MusicBox
2
+
3
+ class Importer
4
+
5
+ def initialize(catalog:)
6
+ @catalog = catalog
7
+ @prompt = TTY::Prompt.new
8
+ end
9
+
10
+ def import_dir(source_dir)
11
+ @source_dir = Path.new(source_dir).realpath
12
+ puts; puts "Importing from #{@source_dir}"
13
+ find_release
14
+ determine_disc
15
+ make_album
16
+ make_copy_plan
17
+ if @prompt.yes?('Add?')
18
+ import_album
19
+ make_label if @prompt.yes?('Make label?')
20
+ make_cover if @prompt.yes?('Make cover?')
21
+ end
22
+ end
23
+
24
+ def determine_disc
25
+ @disc = nil
26
+ if @album
27
+ raise Error, "Album already exists" if @release.format_quantity.nil? || @release.format_quantity == 1
28
+ puts "Release has multiple discs."
29
+ n = @prompt.ask?('Which disc is this?', required: true, convert: :int)
30
+ raise Error, "Disc number out of range" unless n >= 1 && n <= @release.format_quantity
31
+ @disc = n
32
+ end
33
+ end
34
+
35
+ def find_release
36
+ releases = @catalog.find(@source_dir.basename.to_s,
37
+ group: :releases,
38
+ prompt: true,
39
+ multiple: false)
40
+ @release = releases.first
41
+ @tracklist_flattened = @release.tracklist_flattened
42
+ print @release.details_to_s
43
+ end
44
+
45
+ def make_album
46
+ @album = Catalog::Album.new(
47
+ id: @release.id,
48
+ title: @release.title,
49
+ artist: @release.artist,
50
+ year: @release.original_release_year,
51
+ discs: @release.format_quantity,
52
+ dir: @catalog.albums.dir_for_id(@release.id))
53
+ @release.album = @album
54
+ end
55
+
56
+ def make_album_track(file)
57
+ tags = Catalog::Tags.load(file)
58
+ release_track = find_track_for_title(tags[:title])
59
+ name = '%s%02d - %s' % [
60
+ @disc ? ('%1d-' % @disc) : '',
61
+ tags[:track],
62
+ release_track.title.gsub(%r{[/:]}, '_'),
63
+ ]
64
+ album_track = Catalog::AlbumTrack.new(
65
+ title: release_track.title,
66
+ artist: release_track.artist || @release.artist,
67
+ track: tags[:track],
68
+ disc: @disc || tags[:disc],
69
+ file: Path.new(name).add_extension(file.extname),
70
+ tags: tags,
71
+ album: @album)
72
+ puts "%-50s ==> %6s - %-50s ==> %-50s" % [
73
+ file.basename,
74
+ release_track.position,
75
+ release_track.title,
76
+ album_track.file,
77
+ ]
78
+ album_track
79
+ end
80
+
81
+ def find_track_for_title(title)
82
+ release_track = @tracklist_flattened.find { |t| t.title.downcase == title.downcase }
83
+ unless release_track
84
+ puts "Can't find release track with title #{title.inspect}"
85
+ choices = @tracklist_flattened.map { |t| [t.title, t] }.to_h
86
+ release_track = @prompt.select('Track?', choices, per_page: 100)
87
+ end
88
+ release_track
89
+ end
90
+
91
+ def import_album
92
+ raise Error, "No tracks were added to album" if @album.tracks.empty?
93
+ @album.save
94
+ copy_files
95
+ @album.update_tags
96
+ extract_cover
97
+ end
98
+
99
+ def make_copy_plan
100
+ @copy_plan = @source_dir.children.select(&:file?).sort.map do |source_file|
101
+ dest_file = case source_file.extname.downcase
102
+ when '.m4a'
103
+ album_track = make_album_track(source_file) or next
104
+ @album.tracks << album_track
105
+ album_track.file
106
+ else
107
+ source_file.basename
108
+ end
109
+ [source_file, @album.dir / dest_file]
110
+ end.to_h
111
+ end
112
+
113
+ def copy_files
114
+ @copy_plan.each do |source_file, dest_file|
115
+ source_file.cp(dest_file)
116
+ end
117
+ @catalog.import_done_dir.mkpath unless @catalog.import_done_dir.exist?
118
+ @source_dir.rename(@catalog.import_done_dir / @source_dir.basename)
119
+ end
120
+
121
+ def make_label
122
+ output_file = '/tmp/labels.pdf'
123
+ LabelMaker.make_labels(@release.to_label, output_file: output_file)
124
+ run_command('open', output_file)
125
+ end
126
+
127
+ def make_cover
128
+ #FIXME
129
+ end
130
+
131
+ end
132
+
133
+ end
@@ -0,0 +1,18 @@
1
+ class MusicBox
2
+
3
+ def self.info_to_s(info, indent: 0)
4
+ io = StringIO.new
5
+ width = info.map { |i| i.first.length }.max
6
+ info.each do |label, value, sub_info|
7
+ io.puts '%s%*s: %s' % [
8
+ ' ' * indent,
9
+ width,
10
+ label,
11
+ value,
12
+ ]
13
+ io.print info_to_s(sub_info, indent: indent + width + 2) if sub_info
14
+ end
15
+ io.string
16
+ end
17
+
18
+ end
@@ -0,0 +1,61 @@
1
+ class MusicBox
2
+
3
+ class LabelMaker
4
+
5
+ def self.make_labels(*labels, output_file:)
6
+ label_maker = new
7
+ label_maker.make_labels(labels)
8
+ label_maker.write(output_file)
9
+ end
10
+
11
+ def initialize
12
+ @font_dir = Path.new('~/Fonts/D/DejaVu Sans')
13
+ @pdf = Prawn::Document.new(page_size: [3.5.in, 1.14.in], margin: 0)
14
+ @pdf.font_families.update(font_families)
15
+ @pdf.font('DejaVuSans')
16
+ @pdf.font_size(12)
17
+ end
18
+
19
+ def make_labels(labels)
20
+ labels.sort_by { |l| l.values_at(:key, :year) }.each_with_index do |label, i|
21
+ @pdf.start_new_page if i > 0
22
+ @pdf.bounding_box([0, 1.in], width: 2.5.in, height: 1.in) do
23
+ # ;;@pdf.transparent(0.5) { @pdf.stroke_bounds }
24
+ @pdf.text_box <<~END, inline_format: true
25
+ <b>#{label[:artist]}</b>
26
+ <i>#{label[:title]}</i>
27
+ END
28
+ end
29
+ @pdf.bounding_box([2.7.in, 1.in], width: 0.8.in, height: 1.in) do
30
+ # ;;@pdf.transparent(0.5) { @pdf.stroke_bounds }
31
+ @pdf.text_box <<~END, align: :right, inline_format: true
32
+ <b>#{label[:key]}
33
+ #{label[:year]}</b>
34
+
35
+ #{label[:format]}
36
+ #{label[:id]}
37
+ END
38
+ end
39
+ end
40
+ end
41
+
42
+ def write(output_file)
43
+ @pdf.render_file(output_file.to_s)
44
+ end
45
+
46
+ def font_families
47
+ {
48
+ 'DejaVuSans' => {
49
+ normal: 'DejaVuSans',
50
+ italic: 'DejaVuSans-Oblique',
51
+ bold: 'DejaVuSans-Bold',
52
+ bold_italic: 'DejaVuSans-BoldOblique',
53
+ }.map { |style, file|
54
+ [style, (@font_dir / file).add_extension('.ttf').realpath.to_s ]
55
+ }.to_h
56
+ }
57
+ end
58
+
59
+ end
60
+
61
+ end
@@ -0,0 +1,289 @@
1
+ class MusicBox
2
+
3
+ class Player
4
+
5
+ Keymap = {
6
+ 'a' => :play_random_album,
7
+ 't' => :play_random_tracks,
8
+ 'r' => :play_album_for_current_track,
9
+ 'n' => :play_next_track,
10
+ 'p' => :play_previous_track,
11
+ ' ' => :toggle_pause,
12
+ '<' => :skip_backward,
13
+ '>' => :skip_forward,
14
+ '^' => :skip_to_beginning,
15
+ 'e' => :toggle_equalizer,
16
+ 'E' => :next_equalizer,
17
+ 'q' => :quit,
18
+ '.' => :show_playlist,
19
+ '?' => :show_keymap,
20
+ }
21
+ ObservedProperties = {
22
+ 'playlist' => :playlist,
23
+ 'playlist-pos' => :playlist_position,
24
+ 'pause' => :pause_state,
25
+ 'time-pos' => :time_position,
26
+ }
27
+ SeekSeconds = 30
28
+
29
+ attr_accessor :albums
30
+ attr_accessor :audio_device
31
+ attr_accessor :mpv_log_level
32
+ attr_accessor :equalizers
33
+
34
+ def initialize(**params)
35
+ {
36
+ mpv_log_level: 'error',
37
+ }.merge(params).each { |k, v| send("#{k}=", v) }
38
+ @playlist_file = Path.new('/tmp/mpv_playlist')
39
+ end
40
+
41
+ def play
42
+ raise Error, "No albums to play" if @albums.nil? || @albums.empty?
43
+ read_albums
44
+ @dispatcher = IO::Dispatcher.new
45
+ setup_interface
46
+ setup_mpv
47
+ puts "[ready]"
48
+ play_random_album
49
+ @dispatcher.run
50
+ end
51
+
52
+ def setup_mpv
53
+ @mpv = MPVClient.new(
54
+ 'mpv-log-level' => @mpv_log_level,
55
+ 'audio-device' => @audio_device,
56
+ 'audio-display' => 'no',
57
+ 'vo' => 'null',
58
+ 'volume' => 100)
59
+ @mpv.register_event('log-message') do |event|
60
+ ;;pp(log: event)
61
+ end
62
+ @mpv.command('request_log_messages', @mpv_log_level) if @mpv_log_level
63
+ @properties = HashStruct.new
64
+ ObservedProperties.each do |name, key|
65
+ @mpv.observe_property(name) { |n, v| property_changed(n, v) }
66
+ end
67
+ if @equalizers
68
+ @equalizer_enabled = true
69
+ next_equalizer
70
+ end
71
+ @dispatcher.add_io_handler(input: @mpv.socket) do |io|
72
+ @mpv.process_response
73
+ end
74
+ @dispatcher.add_io_handler(exception: @mpv.socket) do |io|
75
+ shutdown_mpv
76
+ end
77
+ at_exit { shutdown_mpv }
78
+ end
79
+
80
+ def shutdown_mpv
81
+ if @mpv
82
+ @mpv.command('quit')
83
+ @dispatcher.remove_io_handler(input: @mpv.socket, exception: @mpv.socket)
84
+ @mpv.stop
85
+ @mpv = nil
86
+ end
87
+ end
88
+
89
+ def setup_interface
90
+ @stty_old_params = `stty -g`.chomp
91
+ at_exit { system('stty', @stty_old_params) }
92
+ system('stty', 'cbreak', '-echo')
93
+ @dispatcher.add_io_handler(input: STDIN) do |io|
94
+ key = io.sysread(1)
95
+ if (command = Keymap[key])
96
+ puts "[#{command_description(command)}]"
97
+ send(command)
98
+ else
99
+ puts "unknown key: %p" % key
100
+ end
101
+ end
102
+ end
103
+
104
+ def read_albums
105
+ @album_for_track_path = {}
106
+ @albums.each do |album|
107
+ album.tracks.each do |track|
108
+ @album_for_track_path[track.path] = album
109
+ end
110
+ end
111
+ end
112
+
113
+ def random_album
114
+ @albums.shuffle.first
115
+ end
116
+
117
+ def random_tracks(length:)
118
+ tracks = Set.new
119
+ while tracks.length < length
120
+ tracks << random_album.tracks.shuffle.first
121
+ end
122
+ tracks.to_a
123
+ end
124
+
125
+ def playlist_changed(value)
126
+ @current_track = @playlist = nil
127
+ if @properties.playlist
128
+ @playlist = @properties.playlist.map do |entry|
129
+ track_path = Path.new(entry.filename)
130
+ album = @album_for_track_path[track_path] \
131
+ or raise Error, "Can't determine album for track file: #{track_path}"
132
+ track = album.tracks.find { |t| t.path == track_path } \
133
+ or raise Error, "Can't determine track for track file: #{track_path}"
134
+ @current_track = track if entry.current
135
+ track
136
+ end
137
+ end
138
+ show_playlist
139
+ end
140
+
141
+ #
142
+ # commands called by interface
143
+ #
144
+
145
+ def quit
146
+ Kernel.exit(0)
147
+ end
148
+
149
+ def play_next_track
150
+ if @properties.playlist_position && @properties.playlist_position < @properties.playlist.count - 1
151
+ @mpv.command('playlist-next')
152
+ else
153
+ puts 'no next track'
154
+ end
155
+ end
156
+
157
+ def play_previous_track
158
+ if @properties.playlist_position && @properties.playlist_position > 0
159
+ @mpv.command('playlist-prev')
160
+ else
161
+ puts 'no previous track'
162
+ end
163
+ end
164
+
165
+ def play_random_album
166
+ play_tracks(random_album.tracks)
167
+ end
168
+
169
+ def play_random_tracks
170
+ play_tracks(random_tracks(length: 10))
171
+ end
172
+
173
+ def play_album_for_current_track
174
+ if @properties.playlist_position
175
+ entry = @properties.playlist[@properties.playlist_position]
176
+ track_path = Path.new(entry.filename)
177
+ album = @album_for_track_path[track_path] \
178
+ or raise Error, "Can't determine album for track file: #{track_path}"
179
+ play_tracks(album.tracks)
180
+ else
181
+ puts "no current track"
182
+ end
183
+ end
184
+
185
+ def toggle_pause
186
+ @mpv.set_property('pause', !@properties.pause_state)
187
+ end
188
+
189
+ def skip_backward
190
+ if @properties.time_position && @properties.time_position > 0
191
+ @mpv.command('seek', -SeekSeconds)
192
+ end
193
+ end
194
+
195
+ def skip_forward
196
+ if @properties.time_position
197
+ @mpv.command('seek', SeekSeconds)
198
+ end
199
+ end
200
+
201
+ def skip_to_beginning
202
+ if @properties.time_position && @properties.time_position > 0
203
+ @mpv.command('seek', 0, 'absolute-percent')
204
+ end
205
+ end
206
+
207
+ def show_playlist
208
+ if @playlist
209
+ system('clear')
210
+ if @current_track
211
+ @current_track.album.show_cover(width: 'auto', height: 20, preserve_aspect_ratio: false)
212
+ puts
213
+ end
214
+ @playlist.each_with_index do |track, i|
215
+ puts '%1s %2d. %-40.40s | %-40.40s | %-40.40s' % [
216
+ track == @current_track ? '>' : '',
217
+ i + 1,
218
+ track.title,
219
+ track.album.title,
220
+ track.album.artist,
221
+ ]
222
+ end
223
+ end
224
+ end
225
+
226
+ def show_keymap
227
+ Keymap.each do |key, command|
228
+ puts "%-8s %s" % [key_description(key), command_description(command)]
229
+ end
230
+ end
231
+
232
+ def play_tracks(tracks)
233
+ @playlist_file.dirname.mkpath
234
+ @playlist_file.write(tracks.map(&:path).join("\n"))
235
+ @mpv.command('loadlist', @playlist_file.to_s)
236
+ end
237
+
238
+ def toggle_equalizer
239
+ @equalizer_enabled = !@equalizer_enabled
240
+ set_current_equalizer
241
+ end
242
+
243
+ def next_equalizer
244
+ if @equalizers
245
+ @current_equalizer &&= @equalizers[@equalizers.index(@current_equalizer) + 1]
246
+ @current_equalizer ||= @equalizers.first
247
+ set_current_equalizer
248
+ end
249
+ end
250
+
251
+ def set_current_equalizer
252
+ if @current_equalizer
253
+ puts "[equalizer: %s <%s>]" % [
254
+ @current_equalizer.name,
255
+ @equalizer_enabled ? 'enabled' : 'disabled',
256
+ ]
257
+ @mpv.command('af', 'set', @current_equalizer.to_s(enabled: @equalizer_enabled))
258
+ end
259
+ end
260
+
261
+ #
262
+ # callbacks from MPV
263
+ #
264
+
265
+ def property_changed(name, value)
266
+ # ;;pp(name => value) unless name == 'time-pos'
267
+ key = ObservedProperties[name] or raise
268
+ @properties[key] = value
269
+ send("#{key}_changed", value) rescue NoMethodError
270
+ end
271
+
272
+ private
273
+
274
+ def key_description(key)
275
+ case key
276
+ when ' '
277
+ 'space'
278
+ else
279
+ key
280
+ end
281
+ end
282
+
283
+ def command_description(command)
284
+ command.to_s.gsub('_', ' ')
285
+ end
286
+
287
+ end
288
+
289
+ end