wavesync 1.0.0.alpha2 → 1.0.0.alpha4

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +95 -59
  3. data/config/devices.yml +20 -0
  4. data/lib/wavesync/acid_chunk.rb +45 -4
  5. data/lib/wavesync/analyzer.rb +17 -4
  6. data/lib/wavesync/audio.rb +153 -90
  7. data/lib/wavesync/audio_format.rb +9 -2
  8. data/lib/wavesync/bpm_detector.rb +14 -7
  9. data/lib/wavesync/cli.rb +3 -0
  10. data/lib/wavesync/commands/analyze.rb +2 -0
  11. data/lib/wavesync/commands/clear_cache.rb +75 -0
  12. data/lib/wavesync/commands/command.rb +4 -1
  13. data/lib/wavesync/commands/help.rb +6 -0
  14. data/lib/wavesync/commands/pull.rb +43 -0
  15. data/lib/wavesync/commands/setlist.rb +66 -0
  16. data/lib/wavesync/commands/sync.rb +19 -26
  17. data/lib/wavesync/commands.rb +52 -12
  18. data/lib/wavesync/config.rb +43 -3
  19. data/lib/wavesync/cue_chunk.rb +203 -0
  20. data/lib/wavesync/device.rb +32 -7
  21. data/lib/wavesync/essentia_bpm_detector.rb +38 -0
  22. data/lib/wavesync/ffmpeg/probe.rb +74 -0
  23. data/lib/wavesync/ffmpeg.rb +144 -0
  24. data/lib/wavesync/file_converter.rb +7 -2
  25. data/lib/wavesync/libmtp.rb +333 -0
  26. data/lib/wavesync/logger.rb +84 -0
  27. data/lib/wavesync/path_resolver.rb +32 -6
  28. data/lib/wavesync/percival_bpm_detector.rb +31 -0
  29. data/lib/wavesync/python_venv.rb +25 -0
  30. data/lib/wavesync/scanner.rb +143 -27
  31. data/lib/wavesync/{set.rb → setlist.rb} +28 -12
  32. data/lib/wavesync/setlist_editor.rb +556 -0
  33. data/lib/wavesync/track_padding.rb +15 -4
  34. data/lib/wavesync/transport/filesystem.rb +36 -0
  35. data/lib/wavesync/transport/mtp.rb +285 -0
  36. data/lib/wavesync/transport.rb +21 -0
  37. data/lib/wavesync/ui.rb +67 -12
  38. data/lib/wavesync/version.rb +2 -1
  39. data/lib/wavesync.rb +7 -2
  40. metadata +17 -32
  41. data/lib/wavesync/commands/set.rb +0 -63
  42. data/lib/wavesync/set_editor.rb +0 -245
@@ -1,52 +1,131 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
- require 'streamio-ffmpeg'
4
4
  require 'securerandom'
5
5
  require 'tmpdir'
6
6
  require 'fileutils'
7
- require 'taglib'
7
+ require_relative 'logger'
8
8
 
9
9
  module Wavesync
10
10
  class Audio
11
11
  SUPPORTED_FORMATS = %w[.m4a .mp3 .wav .aif .aiff].freeze
12
12
 
13
+ #: (String library_path) -> Array[String]
13
14
  def self.find_all(library_path)
14
15
  Dir.glob(File.join(library_path, '**', '*'))
15
- .select { |f| SUPPORTED_FORMATS.include?(File.extname(f).downcase) }
16
+ .select { |file| SUPPORTED_FORMATS.include?(File.extname(file).downcase) }
17
+ .sort_by(&:downcase)
16
18
  end
17
19
 
20
+ #: (String file_path) -> void
18
21
  def initialize(file_path)
19
- @file_path = file_path
20
- @file_ext = File.extname(@file_path).downcase
21
- @audio = FFMPEG::Movie.new(file_path)
22
+ @file_path = file_path #: String
23
+ @file_ext = File.extname(@file_path).downcase #: String
24
+ @audio = Wavesync::FFMPEG::Probe.new(file_path) #: Wavesync::FFMPEG::Probe
22
25
  end
23
26
 
27
+ #: () -> Float
24
28
  def duration
25
29
  @audio.duration
26
30
  end
27
31
 
32
+ #: () -> Integer?
28
33
  def sample_rate
29
- @sample_rate ||= @audio.audio_sample_rate
34
+ @sample_rate ||= @audio.sample_rate
30
35
  end
31
36
 
37
+ #: () -> Integer?
32
38
  def bit_depth
33
- @bit_depth ||= calculate_bit_depth
39
+ @bit_depth ||= @audio.bit_depth
34
40
  end
35
41
 
42
+ #: () -> Integer?
43
+ def bitrate
44
+ @bitrate ||= @audio.bitrate
45
+ end
46
+
47
+ #: () -> (String | Integer)?
36
48
  def bpm
37
49
  return @bpm if defined?(@bpm)
38
50
 
39
51
  @bpm = bpm_from_file
40
52
  end
41
53
 
54
+ #: () -> AudioFormat
42
55
  def format
43
56
  AudioFormat.new(
44
57
  file_type: @file_ext.delete_prefix('.'),
45
58
  sample_rate: sample_rate,
46
- bit_depth: bit_depth
59
+ bit_depth: bit_depth,
60
+ bitrate: bitrate
47
61
  )
48
62
  end
49
63
 
64
+ #: () -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
65
+ def cue_points
66
+ return [] unless @file_ext == '.wav'
67
+
68
+ CueChunk.read(@file_path)
69
+ end
70
+
71
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
72
+ def write_cue_points(cue_points)
73
+ temp_path = "#{@file_path}.tmp"
74
+ CueChunk.write(@file_path, temp_path, cue_points)
75
+ FileUtils.mv(temp_path, @file_path)
76
+ end
77
+
78
+ ID3V2_FRAME_TITLE = 'TIT2'
79
+ ID3V2_FRAME_ARTIST = 'TPE1'
80
+ ID3V2_FRAME_ALBUM = 'TALB'
81
+ ID3V2_FRAME_ALBUM_ARTIST = 'TPE2'
82
+ ID3V2_FRAME_GENRE = 'TCON'
83
+ ID3V2_FRAME_COMPOSER = 'TCOM'
84
+ ID3V2_FRAME_ENCODED_BY = 'TENC'
85
+ ID3V2_FRAME_COMPILATION = 'TCMP'
86
+
87
+ FRAME_ID_TO_FFMPEG_KEY = {
88
+ ID3V2_FRAME_TITLE => 'title',
89
+ ID3V2_FRAME_ARTIST => 'artist',
90
+ ID3V2_FRAME_ALBUM => 'album',
91
+ ID3V2_FRAME_ALBUM_ARTIST => 'album_artist',
92
+ ID3V2_FRAME_GENRE => 'genre',
93
+ ID3V2_FRAME_COMPOSER => 'composer',
94
+ ID3V2_FRAME_ENCODED_BY => 'encoded_by',
95
+ ID3V2_FRAME_COMPILATION => 'compilation'
96
+ }.freeze
97
+
98
+ FFMPEG_KEY_TO_FRAME_ID = FRAME_ID_TO_FFMPEG_KEY.invert.freeze
99
+
100
+ COMBINING_MARKS = /\p{Mn}/
101
+
102
+ #: () -> Hash[String, String]
103
+ def transliterated_tag_changes
104
+ current_tags = @audio.tags
105
+ changes = {} #: Hash[String, String]
106
+
107
+ FRAME_ID_TO_FFMPEG_KEY.each_value do |ffmpeg_key|
108
+ current_value = find_in_tags(current_tags, ffmpeg_key)
109
+ next if current_value.nil?
110
+
111
+ transliterated = transliterate(current_value)
112
+ changes[ffmpeg_key] = transliterated if transliterated != current_value
113
+ end
114
+
115
+ changes
116
+ end
117
+
118
+ #: () -> void
119
+ def transliterate_tags
120
+ return unless @file_ext == '.mp3'
121
+
122
+ changes = transliterated_tag_changes
123
+ return if changes.empty?
124
+
125
+ write_file_metadata(changes)
126
+ end
127
+
128
+ #: (String | Integer | Float bpm) -> void
50
129
  def write_bpm(bpm)
51
130
  case @file_ext
52
131
  when '.m4a'
@@ -61,20 +140,26 @@ module Wavesync
61
140
  @bpm = bpm
62
141
  end
63
142
 
64
- def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil)
65
- options = build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds)
143
+ #: (String target_path, ?target_sample_rate: Integer?, ?target_file_type: String?, ?target_bit_depth: Integer?, ?padding_seconds: Numeric?, ?metadata: Hash[String, String], ?target_bitrate: Integer) ?{ (String) -> void } -> bool
144
+ def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil, metadata: {}, target_bitrate: 192)
66
145
  ext = target_file_type || @file_ext.delete_prefix('.')
67
- temp_path = File.join(
68
- Dir.tmpdir,
69
- "wavesync_transcode_#{SecureRandom.hex}.#{ext}"
70
- )
146
+ temp_path = File.join(Dir.tmpdir, "wavesync_transcode_#{SecureRandom.hex}.#{ext}")
71
147
 
72
148
  begin
73
- @audio.transcode(temp_path, options)
149
+ command = Wavesync::FFMPEG.new.input(@file_path).audio_codec(transcode_codec(ext, target_bit_depth))
150
+ command.audio_bitrate("#{target_bitrate}k") if ext == 'mp3'
151
+ command.sample_rate(target_sample_rate) if target_sample_rate
152
+ if padding_seconds&.positive?
153
+ total_duration = @audio.duration + padding_seconds
154
+ command.audio_filter("apad=whole_dur=#{total_duration.round(6)}")
155
+ end
156
+ metadata.each { |key, value| command.metadata(key, value) }
157
+ command.run(temp_path)
158
+ yield temp_path if block_given?
74
159
  FileUtils.install(temp_path, target_path)
75
160
  true
76
- rescue Errno::ENOENT
77
- puts 'Errno::ENOENT'
161
+ rescue Errno::ENOENT => e
162
+ Logger.log_error(e, call_site: 'Audio#transcode', arguments: { target_path:, target_sample_rate:, target_file_type:, target_bit_depth:, padding_seconds:, target_bitrate: })
78
163
  false
79
164
  ensure
80
165
  FileUtils.rm_f(temp_path)
@@ -83,39 +168,14 @@ module Wavesync
83
168
 
84
169
  private
85
170
 
86
- def calculate_bit_depth
87
- data = @audio.metadata
88
- return nil unless data && data[:streams]
89
-
90
- audio_stream = data[:streams].find { |s| s[:codec_type] == 'audio' }
91
- return nil unless audio_stream
92
-
93
- bits_per_sample = audio_stream[:bits_per_sample]
94
- return bits_per_sample if bits_per_sample&.positive?
95
-
96
- nil
97
- end
98
-
99
- def build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds = nil)
100
- options = { custom: %w[-loglevel warning -nostats -hide_banner] }
101
-
102
- if target_bit_depth == 24
103
- options[:audio_codec] = 'pcm_s24le'
104
- elsif target_bit_depth == 16
105
- options[:audio_codec] = 'pcm_s16le'
106
- end
107
-
108
- options[:audio_codec] = 'pcm_s24le'
109
- options[:audio_sample_rate] = target_sample_rate if target_sample_rate
171
+ #: (String target_file_type, Integer? target_bit_depth) -> String
172
+ def transcode_codec(target_file_type, target_bit_depth)
173
+ return 'libmp3lame' if target_file_type == 'mp3'
110
174
 
111
- if padding_seconds&.positive?
112
- total_duration = @audio.duration + padding_seconds
113
- options[:custom] += ['-af', "apad=whole_dur=#{total_duration.round(6)}"]
114
- end
115
-
116
- options
175
+ target_bit_depth == 16 ? 'pcm_s16le' : 'pcm_s24le'
117
176
  end
118
177
 
178
+ #: () -> (String | Integer)?
119
179
  def bpm_from_file
120
180
  case @file_ext
121
181
  when '.m4a'
@@ -129,84 +189,87 @@ module Wavesync
129
189
  end
130
190
  end
131
191
 
192
+ #: () -> Integer?
132
193
  def bpm_from_m4a
133
- TagLib::MP4::File.open(@file_path) do |file|
134
- tag = file.tag
135
- return bpm_from_item_map(tag) if tag
136
- end
194
+ value = find_in_tags(@audio.tags, 'BPM')
195
+ return nil if value.nil?
196
+
197
+ int_value = value.to_i
198
+ int_value.zero? ? nil : int_value
137
199
  end
138
200
 
201
+ #: () -> String?
139
202
  def bpm_from_mp3
140
- TagLib::MPEG::File.open(@file_path) do |file|
141
- tag = file.id3v2_tag
142
- return bpm_from_frame_list(tag) if tag
143
- end
203
+ value = find_in_tags(@audio.tags, 'TBPM')
204
+ value&.then { |v| v.empty? ? nil : v }
144
205
  end
145
206
 
207
+ #: () -> (String | Integer)?
146
208
  def bpm_from_wav
147
- TagLib::RIFF::WAV::File.open(@file_path) do |file|
148
- tag = file.id3v2_tag
149
- bpm_from_frame_list = bpm_from_frame_list(tag) if tag
150
- return bpm_from_frame_list if bpm_from_frame_list
151
- end
209
+ value = find_in_tags(@audio.tags, 'TBPM')
210
+ return value if value && !value.empty?
152
211
 
153
212
  bpm_from_acid_chunk
154
213
  end
155
214
 
215
+ #: () -> String?
156
216
  def bpm_from_aiff
157
- TagLib::RIFF::AIFF::File.open(@file_path) do |file|
158
- tag = file.tag
159
- return bpm_from_frame_list(tag) if tag
160
- end
217
+ value = find_in_tags(@audio.tags, 'TBPM')
218
+ value&.then { |v| v.empty? ? nil : v }
161
219
  end
162
220
 
163
- def bpm_from_item_map(tag)
164
- tmpo = tag.item_map['tmpo']&.to_int
165
- tmpo&.zero? ? nil : tmpo
166
- end
167
-
168
- def bpm_from_frame_list(tag)
169
- tag.frame_list('TBPM').first&.to_s
221
+ #: (Hash[String, String] tags, String key) -> String?
222
+ def find_in_tags(tags, key)
223
+ tags.find { |k, _| k.casecmp(key).zero? }&.last
170
224
  end
171
225
 
226
+ #: () -> Integer?
172
227
  def bpm_from_acid_chunk
173
228
  tmpo = Wavesync::AcidChunk.read_bpm(@file_path).to_i
174
229
  tmpo&.zero? ? nil : tmpo
175
230
  end
176
231
 
232
+ #: (String | Integer | Float bpm) -> void
177
233
  def write_bpm_to_wav(bpm)
178
234
  temp_path = "#{@file_path}.tmp"
179
235
  AcidChunk.write_bpm(@file_path, temp_path, bpm)
180
236
  FileUtils.mv(temp_path, @file_path)
181
237
  end
182
238
 
239
+ #: (String | Integer | Float bpm) -> void
183
240
  def write_bpm_to_mp3(bpm)
184
- TagLib::MPEG::File.open(@file_path) do |file|
185
- write_id3v2_bpm(file.id3v2_tag(true), bpm)
186
- file.save
187
- end
241
+ write_file_metadata('TBPM' => bpm.to_s)
188
242
  end
189
243
 
244
+ #: (String | Integer | Float bpm) -> void
190
245
  def write_bpm_to_m4a(bpm)
191
- TagLib::MP4::File.open(@file_path) do |file|
192
- tag = file.tag
193
- tag.item_map.insert('tmpo', TagLib::MP4::Item.from_int(bpm.to_i))
194
- file.save
195
- end
246
+ write_file_metadata('BPM' => bpm.to_i.to_s)
196
247
  end
197
248
 
249
+ #: (String | Integer | Float bpm) -> void
198
250
  def write_bpm_to_aiff(bpm)
199
- TagLib::RIFF::AIFF::File.open(@file_path) do |file|
200
- write_id3v2_bpm(file.tag, bpm)
201
- file.save
202
- end
251
+ write_file_metadata('TBPM' => bpm.to_s)
252
+ end
253
+
254
+ #: (Hash[String, String] metadata_hash) -> void
255
+ def write_file_metadata(metadata_hash)
256
+ ext = File.extname(@file_path)
257
+ temp_path = File.join(Dir.tmpdir, "wavesync_meta_#{SecureRandom.hex}#{ext}")
258
+ command = FFMPEG.new.input(@file_path).copy_streams.map_metadata(0)
259
+ command.movflags('+use_metadata_tags') if ext == '.m4a'
260
+ command.write_id3v2(1) if %w[.aif .aiff].include?(ext)
261
+ metadata_hash.each { |key, value| command.metadata(key, value) }
262
+ command.run(temp_path)
263
+ FileUtils.mv(temp_path, @file_path)
264
+ ensure
265
+ FileUtils.rm_f(temp_path)
203
266
  end
204
267
 
205
- def write_id3v2_bpm(tag, bpm)
206
- tag.remove_frames('TBPM')
207
- frame = TagLib::ID3v2::TextIdentificationFrame.new('TBPM', TagLib::String::UTF8)
208
- frame.text = bpm.to_s
209
- tag.add_frame(frame)
268
+ #: (String string) -> String
269
+ def transliterate(string)
270
+ string
271
+ .unicode_normalize(:nfd)
272
+ .gsub(COMBINING_MARKS, '')
210
273
  end
211
274
  end
212
275
  end
@@ -1,12 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wavesync
4
- AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth) do
4
+ AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth, :bitrate) do
5
+ #: (file_type: String?, sample_rate: Integer?, bit_depth: Integer?, ?bitrate: Integer?) -> void
6
+ def initialize(file_type:, sample_rate:, bit_depth:, bitrate: nil)
7
+ super
8
+ end
9
+
10
+ #: (AudioFormat other) -> AudioFormat
5
11
  def merge(other)
6
12
  with(
7
13
  file_type: other.file_type || file_type,
8
14
  sample_rate: other.sample_rate || sample_rate,
9
- bit_depth: other.bit_depth || bit_depth
15
+ bit_depth: other.bit_depth || bit_depth,
16
+ bitrate: other.bitrate || bitrate
10
17
  )
11
18
  end
12
19
  end
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
- require 'shellwords'
4
+ require_relative 'essentia_bpm_detector'
5
+ require_relative 'percival_bpm_detector'
4
6
 
5
7
  module Wavesync
6
8
  class BpmDetector
9
+ CONFIDENCE_THRESHOLD = 2.0
10
+
11
+ #: () -> bool?
7
12
  def self.available?
8
- system('which bpm > /dev/null 2>&1') && system('which ffmpeg > /dev/null 2>&1')
13
+ EssentiaBpmDetector.available? || PercivalBpmDetector.available?
9
14
  end
10
15
 
16
+ #: (String file_path) -> Integer?
11
17
  def self.detect(file_path)
12
- output = `ffmpeg -i #{Shellwords.escape(file_path)} -ac 1 -ar 44100 -f f32le - 2>/dev/null | bpm`
13
- bpm = output.strip.to_f
14
- bpm.positive? ? bpm.round : nil
15
- rescue StandardError
16
- nil
18
+ if EssentiaBpmDetector.available?
19
+ essentia_result = EssentiaBpmDetector.detect(file_path)
20
+ return essentia_result[:bpm] if essentia_result && essentia_result[:confidence] > CONFIDENCE_THRESHOLD
21
+ end
22
+
23
+ PercivalBpmDetector.detect(file_path) if PercivalBpmDetector.available?
17
24
  end
18
25
  end
19
26
  end
data/lib/wavesync/cli.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require_relative 'commands'
4
5
 
5
6
  module Wavesync
6
7
  class CLI
8
+ #: () -> void
7
9
  def self.start
10
+ Logger.capture_invocation(ARGV.dup)
8
11
  command_name = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
9
12
  command_class = Commands::ALL.find { |cmd| command_name == cmd.name }
10
13
 
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'optparse'
4
5
 
@@ -11,6 +12,7 @@ module Wavesync
11
12
  self.description = 'Detect and write BPM metadata to library tracks'
12
13
  self.options = [FORCE_OPTION].freeze
13
14
 
15
+ #: () -> void
14
16
  def run
15
17
  options, config = parse_options(banner: 'Usage: wavesync analyze [options]') do |opts, opts_hash|
16
18
  opts.on(*FORCE_OPTION.to_a) { opts_hash[:overwrite] = true }
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'fileutils'
5
+ require 'optparse'
6
+ require_relative '../transport'
7
+
8
+ module Wavesync
9
+ module Commands
10
+ class ClearCache < Command
11
+ DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Clear cache for a specific device only')
12
+
13
+ self.name = 'clear-cache'
14
+ self.description = 'Delete the on-disk staging cache used for MTP devices'
15
+ self.options = [DEVICE_OPTION].freeze
16
+
17
+ #: () -> void
18
+ def run
19
+ options, config = parse_options(banner: 'Usage: wavesync clear-cache [options]') do |opts, opts_hash|
20
+ opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
21
+ end
22
+
23
+ mtp_devices = config.device_configs.select { |device_config| device_config[:transport] == 'mtp' }
24
+ if options[:device]
25
+ mtp_devices = mtp_devices.select { |device_config| device_config[:name] == options[:device] }
26
+ if mtp_devices.empty?
27
+ puts "No MTP device named \"#{options[:device]}\" found in config."
28
+ exit 1
29
+ end
30
+ end
31
+
32
+ if mtp_devices.empty?
33
+ puts 'No MTP devices configured.'
34
+ return
35
+ end
36
+
37
+ mtp_devices.each { |device_config| clear_one(device_config[:name]) }
38
+ end
39
+
40
+ private
41
+
42
+ #: (String device_name) -> void
43
+ def clear_one(device_name)
44
+ path = Wavesync::Transport::Mtp.cache_path(device_name)
45
+ unless File.directory?(path)
46
+ puts "No cache for #{device_name} at #{path}"
47
+ return
48
+ end
49
+
50
+ size_bytes = directory_size(path)
51
+ FileUtils.rm_rf(path)
52
+ puts "Cleared #{path} (#{format_bytes(size_bytes)})"
53
+ end
54
+
55
+ #: (String path) -> Integer
56
+ def directory_size(path)
57
+ Dir.glob(File.join(path, '**', '*'))
58
+ .reject { |entry| File.directory?(entry) }
59
+ .sum { |entry| File.size(entry) }
60
+ end
61
+
62
+ #: (Integer bytes) -> String
63
+ def format_bytes(bytes)
64
+ units = %w[B KB MB GB TB]
65
+ size = bytes.to_f
66
+ unit_index = 0
67
+ while size >= 1024 && unit_index < units.length - 1
68
+ size /= 1024
69
+ unit_index += 1
70
+ end
71
+ unit_index.zero? ? "#{bytes} #{units[unit_index]}" : format('%<size>.1f %<unit>s', size: size, unit: units[unit_index])
72
+ end
73
+ end
74
+ end
75
+ end
@@ -13,8 +13,9 @@ module Wavesync
13
13
  def subcommands = @subcommands || []
14
14
  end
15
15
 
16
+ #: (banner: String) ?{ (OptionParser, Hash[Symbol, untyped]) -> void } -> [Hash[Symbol, untyped], Config]
16
17
  def parse_options(banner:)
17
- options = {}
18
+ options = {} #: Hash[Symbol, untyped]
18
19
  OptionParser.new do |opts|
19
20
  opts.banner = banner
20
21
  opts.on(*CONFIG_OPTION.to_a) { |value| options[:config] = value }
@@ -22,6 +23,8 @@ module Wavesync
22
23
  end.parse!
23
24
  config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
24
25
  config = Commands.load_config(config_path)
26
+ Logger.configure(config.library)
27
+ Logger.log_invocation
25
28
  [options, config]
26
29
  end
27
30
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'optparse'
4
5
  require 'rainbow'
@@ -11,6 +12,7 @@ module Wavesync
11
12
 
12
13
  DESCRIPTION_COLUMN = 23
13
14
 
15
+ #: () -> void
14
16
  def run
15
17
  subcommand_name = ARGV.shift
16
18
 
@@ -30,6 +32,7 @@ module Wavesync
30
32
 
31
33
  private
32
34
 
35
+ #: () -> void
33
36
  def show_general_help
34
37
  puts 'Usage: wavesync [command] [options]'
35
38
  puts ''
@@ -47,6 +50,7 @@ module Wavesync
47
50
  GLOBAL_OPTIONS.each { |option| puts format_option_line(option, indent: 2) }
48
51
  end
49
52
 
53
+ #: (untyped command) -> void
50
54
  def show_command_help(command)
51
55
  if command.subcommands.any?
52
56
  puts "Usage: wavesync #{command.name} <subcommand> [options]"
@@ -68,10 +72,12 @@ module Wavesync
68
72
  end
69
73
  end
70
74
 
75
+ #: (String name, String description) -> String
71
76
  def format_command_line(name, description)
72
77
  " #{name.ljust(DESCRIPTION_COLUMN - 2)}#{description}"
73
78
  end
74
79
 
80
+ #: (untyped option, indent: Integer) -> String
75
81
  def format_option_line(option, indent:)
76
82
  key = "#{option.short}, #{option.long}"
77
83
  Rainbow("#{' ' * indent}#{key.ljust(DESCRIPTION_COLUMN - indent)}#{option.description}").darkgray
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'optparse'
5
+ require_relative '../transport'
6
+
7
+ module Wavesync
8
+ module Commands
9
+ class Pull < Command
10
+ DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Name of device to pull from (as defined in config)')
11
+
12
+ self.name = 'pull'
13
+ self.description = 'Read cue points from device files and write them back into the source library'
14
+ self.options = [DEVICE_OPTION].freeze
15
+
16
+ #: () -> void
17
+ def run
18
+ options, config = parse_options(banner: 'Usage: wavesync pull [options]') do |opts, opts_hash|
19
+ opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
20
+ end
21
+
22
+ device_pairs = Commands.resolve_device_pairs(config, device_name: options[:device])
23
+ scanner = Wavesync::Scanner.new(config.library)
24
+ ui = Wavesync::UI.new
25
+
26
+ device_pairs.each do |pair|
27
+ device_config = pair[0] #: { name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }
28
+ device = pair[1] #: Wavesync::Device
29
+ transport = Wavesync::Transport.for(device_config)
30
+ Commands.with_mtp_retry(transport, device_config[:name]) do
31
+ next unless transport.is_a?(Wavesync::Transport::Mtp)
32
+
33
+ transport.prepare! do |index, total, relative_path|
34
+ ui.pull_staging_progress(index, total, device)
35
+ ui.file_progress(relative_path)
36
+ end
37
+ end
38
+ scanner.pull_cue_points(transport.working_directory, device)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'optparse'
5
+
6
+ module Wavesync
7
+ module Commands
8
+ class Setlist < Command
9
+ self.name = 'setlist'
10
+ self.subcommands = [
11
+ Subcommand.new(usage: 'setlist create NAME', description: 'Create a new setlist'),
12
+ Subcommand.new(usage: 'setlist edit NAME', description: 'Edit an existing setlist'),
13
+ Subcommand.new(usage: 'setlist list', description: 'List all setlists')
14
+ ].freeze
15
+
16
+ #: () -> void
17
+ def run
18
+ subcommand = ARGV.shift
19
+
20
+ _options, config = parse_options(banner: 'Usage: wavesync setlist <subcommand> [options]')
21
+
22
+ case subcommand
23
+ when 'create'
24
+ name = require_name('create')
25
+ if Wavesync::Setlist.exists?(config.library, name)
26
+ puts "Setlist '#{name}' already exists. Use 'wavesync setlist edit #{name}' to edit it."
27
+ exit 1
28
+ end
29
+ setlist = Wavesync::Setlist.new(config.library, name)
30
+ Wavesync::SetlistEditor.new(setlist, config.library).run
31
+ when 'edit'
32
+ name = require_name('edit')
33
+ unless Wavesync::Setlist.exists?(config.library, name)
34
+ puts "Setlist '#{name}' not found. Use 'wavesync setlist create #{name}' to create it."
35
+ exit 1
36
+ end
37
+ setlist = Wavesync::Setlist.load(config.library, name)
38
+ Wavesync::SetlistEditor.new(setlist, config.library).run
39
+ when 'list'
40
+ setlists = Wavesync::Setlist.all(config.library)
41
+ if setlists.empty?
42
+ puts 'No setlists found.'
43
+ else
44
+ setlists.each { |setlist| puts "#{setlist.name} (#{setlist.tracks.size} tracks)" }
45
+ end
46
+ else
47
+ puts "Unknown subcommand: #{subcommand || '(none)'}"
48
+ puts 'Available subcommands: create, edit, list'
49
+ exit 1
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ #: (String subcommand) -> String
56
+ def require_name(subcommand)
57
+ name = ARGV.shift
58
+ unless name
59
+ puts "Usage: wavesync setlist #{subcommand} <name>"
60
+ exit 1
61
+ end
62
+ name
63
+ end
64
+ end
65
+ end
66
+ end