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
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+ require 'yaml'
7
+ require_relative '../libmtp'
8
+ require_relative '../cue_chunk'
9
+ require_relative '../logger'
10
+
11
+ module Wavesync
12
+ module Transport
13
+ class Mtp
14
+ DEFAULT_CACHE_ROOT = File.join(Dir.home, '.cache', 'wavesync').freeze
15
+ MTP_ILLEGAL_CHARACTERS = %r{[<>:"/\\|?*\x00-\x1f]} #: Regexp
16
+ CACHE_DIR_ILLEGAL_CHARACTERS = /[^a-z0-9._-]/ #: Regexp
17
+ MANIFEST_FILENAME = '.manifest.yml'
18
+
19
+ attr_reader :working_directory #: String
20
+ attr_reader :device_path #: String
21
+ attr_reader :name #: String
22
+
23
+ #: (String name, ?cache_root: String) -> String
24
+ def self.cache_path(name, cache_root: DEFAULT_CACHE_ROOT)
25
+ File.join(cache_root, sanitize_dir_name(name))
26
+ end
27
+
28
+ #: (String name) -> String
29
+ def self.sanitize_dir_name(name)
30
+ sanitized = name.downcase.gsub(CACHE_DIR_ILLEGAL_CHARACTERS, '_')
31
+ sanitized.empty? ? 'device' : sanitized
32
+ end
33
+
34
+ #: ({ name: String, model: String, path: String, transport: String?, mp3_bitrate: Integer } device_config, ?libmtp: Libmtp, ?cache_root: String) -> void
35
+ def initialize(device_config, libmtp: Libmtp.new, cache_root: DEFAULT_CACHE_ROOT)
36
+ @name = device_config[:name]
37
+ @device_path = device_config[:path]
38
+ @libmtp = libmtp
39
+ @working_directory = self.class.cache_path(@name, cache_root: cache_root)
40
+ FileUtils.mkdir_p(@working_directory)
41
+ end
42
+
43
+ #: () ?{ (Integer, Integer, String) -> void } -> void
44
+ def prepare!(&progress)
45
+ device_files = @libmtp.files
46
+ folder_paths = build_folder_paths(@libmtp.folders)
47
+
48
+ candidates = device_files.filter_map do |device_file|
49
+ next unless wav?(device_file.filename)
50
+
51
+ relative_path = relative_path_for(device_file, folder_paths)
52
+ next unless relative_path
53
+
54
+ local_path = File.join(@working_directory, relative_path)
55
+ { device_file: device_file, relative_path: relative_path, local_path: local_path }
56
+ end
57
+
58
+ Dir.mktmpdir('wavesync_mtp_pull') do |tmpdir|
59
+ candidates.each_with_index do |candidate, index|
60
+ progress&.call(index, candidates.size, candidate[:relative_path])
61
+ pull_if_cues_differ(candidate, tmpdir)
62
+ end
63
+ end
64
+ end
65
+
66
+ #: () -> void
67
+ def begin_push!
68
+ @push_files_by_parent = @libmtp.files.group_by(&:parent_id)
69
+ @push_folders_by_parent = @libmtp.folders.group_by(&:parent_id)
70
+ @push_storage_id = primary_storage_id(@push_folders_by_parent)
71
+ @push_manifest = load_manifest
72
+ end
73
+
74
+ #: (String relative_path) -> void
75
+ def push_file!(relative_path)
76
+ files_by_parent = @push_files_by_parent
77
+ folders_by_parent = @push_folders_by_parent
78
+ storage_id = @push_storage_id
79
+ raise Libmtp::Error, 'push_file! called before begin_push!' unless files_by_parent && folders_by_parent && storage_id
80
+
81
+ local_path = File.join(@working_directory, relative_path)
82
+ return unless File.file?(local_path)
83
+
84
+ remote_path = join_remote_paths(@device_path, relative_path)
85
+ entry = { local_path: local_path, relative_path: relative_path, remote_path: remote_path } #: { local_path: String, relative_path: String, remote_path: String }
86
+ push_one(entry, files_by_parent: files_by_parent, folders_by_parent: folders_by_parent, storage_id: storage_id)
87
+ end
88
+
89
+ #: () -> void
90
+ def finish_push!
91
+ save_manifest if @push_manifest
92
+ @push_files_by_parent = nil
93
+ @push_folders_by_parent = nil
94
+ @push_storage_id = nil
95
+ @push_manifest = nil
96
+ @libmtp.close!
97
+ end
98
+
99
+ #: () ?{ (Integer, Integer, String) -> void } -> void
100
+ def commit!(&progress)
101
+ begin_push!
102
+ local_files = enumerate_local_files
103
+ local_files.each_with_index do |entry, index|
104
+ progress&.call(index, local_files.size, entry[:relative_path])
105
+ push_file!(entry[:relative_path])
106
+ end
107
+ finish_push!
108
+ end
109
+
110
+ private
111
+
112
+ #: () -> Array[{ local_path: String, relative_path: String, remote_path: String }]
113
+ def enumerate_local_files
114
+ Dir.glob(File.join(@working_directory, '**', '*'))
115
+ .reject { |path| File.directory?(path) }
116
+ .sort
117
+ .map do |local_path|
118
+ relative_path = local_path.sub(%r{\A#{Regexp.escape(@working_directory)}/?}, '')
119
+ remote_path = join_remote_paths(@device_path, relative_path)
120
+ { local_path: local_path, relative_path: relative_path, remote_path: remote_path }
121
+ end
122
+ end
123
+
124
+ #: ({ local_path: String, relative_path: String, remote_path: String } entry, files_by_parent: Hash[Integer, Array[Libmtp::DeviceFile]], folders_by_parent: Hash[Integer, Array[Libmtp::DeviceFolder]], storage_id: Integer) -> void
125
+ def push_one(entry, files_by_parent:, folders_by_parent:, storage_id:)
126
+ target_dir = File.dirname(entry[:remote_path])
127
+ parent_id = ensure_folder(target_dir, folders_by_parent: folders_by_parent, storage_id: storage_id)
128
+
129
+ filename = sanitize_for_mtp(File.basename(entry[:remote_path]))
130
+ existing = (files_by_parent[parent_id] || []).find { |file| sanitize_for_mtp(file.filename) == filename }
131
+
132
+ local_size = File.size(entry[:local_path])
133
+ manifest = @push_manifest || {} #: Hash[String, Integer]
134
+ manifest_size = manifest[entry[:relative_path]]
135
+
136
+ if existing && (manifest_size == local_size || (manifest_size.nil? && existing.size == local_size))
137
+ manifest[entry[:relative_path]] = local_size
138
+ return
139
+ end
140
+
141
+ if existing
142
+ Logger.log_event("MTP replacing (size mismatch device=#{existing.size} local=#{local_size}): #{entry[:relative_path]}")
143
+ @libmtp.delete_file(id: existing.id)
144
+ files_by_parent[parent_id]&.delete(existing)
145
+ else
146
+ Logger.log_event("MTP uploading new file (#{local_size} bytes): #{entry[:relative_path]}")
147
+ end
148
+
149
+ @libmtp.send_file(
150
+ local_path: entry[:local_path],
151
+ remote_filename: filename,
152
+ parent_id: parent_id,
153
+ storage_id: storage_id
154
+ )
155
+ manifest[entry[:relative_path]] = local_size
156
+ end
157
+
158
+ #: (String remote_path, folders_by_parent: Hash[Integer, Array[Libmtp::DeviceFolder]], storage_id: Integer) -> Integer
159
+ def ensure_folder(remote_path, folders_by_parent:, storage_id:)
160
+ parent_id = 0
161
+ path_components(remote_path).each do |raw_name|
162
+ name = sanitize_for_mtp(raw_name)
163
+ children = folders_by_parent[parent_id] || []
164
+ existing = children.find { |folder| sanitize_for_mtp(folder.name) == name }
165
+ if existing
166
+ parent_id = existing.folder_id
167
+ else
168
+ new_id = @libmtp.create_folder(name: name, parent_id: parent_id, storage_id: storage_id)
169
+ new_folder = Libmtp::DeviceFolder.new(folder_id: new_id, name: name, parent_id: parent_id, storage_id: storage_id)
170
+ (folders_by_parent[parent_id] ||= []) << new_folder
171
+ parent_id = new_id
172
+ end
173
+ end
174
+ parent_id
175
+ end
176
+
177
+ #: (String name) -> String
178
+ def normalize_unicode(name)
179
+ name.unicode_normalize(:nfc)
180
+ end
181
+
182
+ #: () -> String
183
+ def manifest_path
184
+ File.join(@working_directory, MANIFEST_FILENAME)
185
+ end
186
+
187
+ #: () -> Hash[String, Integer]
188
+ def load_manifest
189
+ return {} unless File.exist?(manifest_path)
190
+
191
+ data = YAML.safe_load_file(manifest_path)
192
+ data.is_a?(Hash) ? data : {}
193
+ rescue Psych::SyntaxError
194
+ {}
195
+ end
196
+
197
+ #: () -> void
198
+ def save_manifest
199
+ manifest = @push_manifest
200
+ return unless manifest
201
+
202
+ File.write(manifest_path, manifest.to_yaml)
203
+ end
204
+
205
+ #: (String name) -> String
206
+ def sanitize_for_mtp(name)
207
+ normalize_unicode(name).gsub(MTP_ILLEGAL_CHARACTERS, '')
208
+ end
209
+
210
+ #: (Hash[Integer, Array[Libmtp::DeviceFolder]] folders_by_parent) -> Integer
211
+ def primary_storage_id(folders_by_parent)
212
+ first_storage = folders_by_parent.values.flatten.map(&:storage_id).compact.first
213
+ raise Libmtp::DeviceNotFound, 'No MTP device detected. Connect the device and unmount it from any other app.' unless first_storage
214
+
215
+ first_storage
216
+ end
217
+
218
+ #: (String path) -> Array[String]
219
+ def path_components(path)
220
+ path.split('/').reject { |component| component.empty? || component == '.' }
221
+ end
222
+
223
+ #: (String left, String right) -> String
224
+ def join_remote_paths(left, right)
225
+ "#{left}/#{right}".split('/').reject(&:empty?).join('/')
226
+ end
227
+
228
+ #: (Array[Libmtp::DeviceFolder] folders) -> Hash[Integer, String]
229
+ def build_folder_paths(folders)
230
+ by_id = {} #: Hash[Integer, Libmtp::DeviceFolder]
231
+ folders.each { |folder| by_id[folder.folder_id] = folder }
232
+ paths = { 0 => '' } #: Hash[Integer, String]
233
+ folders.each { |folder| resolve_folder_path(folder.folder_id, by_id, paths) }
234
+ paths
235
+ end
236
+
237
+ #: (Integer folder_id, Hash[Integer, Libmtp::DeviceFolder] by_id, Hash[Integer, String] paths) -> String?
238
+ def resolve_folder_path(folder_id, by_id, paths)
239
+ return paths[folder_id] if paths.key?(folder_id)
240
+
241
+ folder = by_id[folder_id]
242
+ return nil unless folder
243
+
244
+ parent_path = resolve_folder_path(folder.parent_id, by_id, paths) || ''
245
+ full_path = parent_path.empty? ? folder.name : "#{parent_path}/#{folder.name}"
246
+ paths[folder_id] = full_path
247
+ full_path
248
+ end
249
+
250
+ #: (Libmtp::DeviceFile device_file, Hash[Integer, String] folder_paths) -> String?
251
+ def relative_path_for(device_file, folder_paths)
252
+ folder_path = folder_paths[device_file.parent_id]
253
+ return nil unless folder_path
254
+
255
+ full_path = folder_path.empty? ? device_file.filename : "#{folder_path}/#{device_file.filename}"
256
+ device_root = path_components(@device_path).join('/')
257
+ return full_path if device_root.empty?
258
+ return full_path[(device_root.length + 1)..] if full_path.start_with?("#{device_root}/")
259
+
260
+ nil
261
+ end
262
+
263
+ #: (String filename) -> bool
264
+ def wav?(filename)
265
+ File.extname(filename).downcase == '.wav'
266
+ end
267
+
268
+ #: ({ device_file: Libmtp::DeviceFile, relative_path: String, local_path: String } candidate, String tmpdir) -> void
269
+ def pull_if_cues_differ(candidate, tmpdir)
270
+ device_file = candidate[:device_file]
271
+ local_path = candidate[:local_path]
272
+ tmp_path = File.join(tmpdir, "#{device_file.id}.wav")
273
+
274
+ @libmtp.get_file(id: device_file.id, local_path: tmp_path)
275
+
276
+ device_cues = CueChunk.read(tmp_path)
277
+ local_cues = File.exist?(local_path) ? CueChunk.read(local_path) : [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?}]
278
+ return if CueChunk.same?(device_cues, local_cues)
279
+
280
+ FileUtils.mkdir_p(File.dirname(local_path))
281
+ FileUtils.mv(tmp_path, local_path)
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Wavesync
5
+ module Transport
6
+ SUPPORTED_KINDS = %w[filesystem mtp].freeze
7
+
8
+ #: ({ name: String, model: String, path: String, transport: String?, mp3_bitrate: Integer } device_config) -> (Filesystem | Mtp)
9
+ def self.for(device_config)
10
+ kind = device_config[:transport] || 'filesystem'
11
+ case kind
12
+ when 'filesystem' then Filesystem.new(device_config)
13
+ when 'mtp' then Mtp.new(device_config)
14
+ else raise ArgumentError, "Unsupported transport: #{kind.inspect}. Supported: #{SUPPORTED_KINDS.join(', ')}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ require_relative 'transport/filesystem'
21
+ require_relative 'transport/mtp'
data/lib/wavesync/ui.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'tty-cursor'
4
5
  require 'tty-prompt'
@@ -15,12 +16,14 @@ module Wavesync
15
16
  extra: :deepskyblue
16
17
  }.freeze
17
18
 
19
+ #: () -> void
18
20
  def initialize
19
- @cursor = TTY::Cursor
20
- @sticky_lines = []
21
- @prompt = TTY::Prompt.new(interrupt: :exit, active_color: :red)
21
+ @cursor = TTY::Cursor #: untyped
22
+ @sticky_lines = [] #: Array[String]
23
+ @prompt = TTY::Prompt.new(interrupt: :exit, active_color: :red) #: untyped
22
24
  end
23
25
 
26
+ #: (String filename) -> void
24
27
  def file_progress(filename)
25
28
  path = Pathname.new(filename)
26
29
  file_stem = path.basename(path.extname).to_s
@@ -29,6 +32,7 @@ module Wavesync
29
32
  sticky(in_color(file_stem, :tertiary), 2)
30
33
  end
31
34
 
35
+ #: (Integer index, Integer total_count, Device device) -> void
32
36
  def sync_progress(index, total_count, device)
33
37
  parts = [
34
38
  in_color("wavesync #{device.name}", :primary),
@@ -38,11 +42,12 @@ module Wavesync
38
42
  sticky(parts.join(' '), 0)
39
43
  end
40
44
 
41
- def conversion_progress(source_format, target_format)
45
+ #: (AudioFormat source_format, AudioFormat target_format, Integer mp3_bitrate) -> void
46
+ def conversion_progress(source_format, target_format, mp3_bitrate)
42
47
  effective = source_format.merge(target_format)
43
48
 
44
- source_info = audio_info(source_format.sample_rate, source_format.bit_depth)
45
- target_info = audio_info(effective.sample_rate, effective.bit_depth)
49
+ source_info = audio_info(source_format)
50
+ target_info = target_audio_info(effective, mp3_bitrate)
46
51
 
47
52
  formatted_line = in_color(
48
53
  "Converting #{source_format.file_type} (#{source_info}) ⇢ #{effective.file_type} (#{target_info})", :highlight
@@ -50,16 +55,20 @@ module Wavesync
50
55
  sticky(formatted_line, 3)
51
56
  end
52
57
 
58
+ #: (AudioFormat source_format) -> void
53
59
  def copy(source_format)
54
- info = audio_info(source_format.sample_rate, source_format.bit_depth)
60
+ info = audio_info(source_format)
55
61
 
56
62
  sticky(in_color("Copying #{source_format.file_type} (#{info})", :highlight), 3)
57
63
  end
58
64
 
59
- def skip
60
- sticky(in_color('↷ Skipping, already synced', :highlight), 3)
65
+ #: (?staged: bool) -> void
66
+ def skip(staged: false)
67
+ message = staged ? '↷ Already in cache' : '↷ Already on device'
68
+ sticky(in_color(message, :highlight), 3)
61
69
  end
62
70
 
71
+ #: ((String | Integer)? tbpm, ?original_bars: Integer?, ?target_bars: Integer?) -> void
63
72
  def bpm(tbpm, original_bars: nil, target_bars: nil)
64
73
  if tbpm.nil?
65
74
  sticky('', 4)
@@ -71,6 +80,27 @@ module Wavesync
71
80
  end
72
81
  end
73
82
 
83
+ #: (Integer index, Integer total_count, Device device) -> void
84
+ def pull_progress(index, total_count, device)
85
+ parts = [
86
+ in_color("wavesync pull #{device.name}", :primary),
87
+ in_color("#{index + 1}/#{total_count}", :extra)
88
+ ]
89
+
90
+ sticky(parts.join(' '), 0)
91
+ end
92
+
93
+ #: (Integer index, Integer total_count, Device device) -> void
94
+ def pull_staging_progress(index, total_count, device)
95
+ parts = [
96
+ in_color("wavesync pull #{device.name} (staging)", :primary),
97
+ in_color("#{index + 1}/#{total_count}", :extra)
98
+ ]
99
+
100
+ sticky(parts.join(' '), 0)
101
+ end
102
+
103
+ #: (Integer index, Integer total_count) -> void
74
104
  def analyze_progress(index, total_count)
75
105
  parts = [
76
106
  in_color('wavesync analyze', :primary),
@@ -79,29 +109,35 @@ module Wavesync
79
109
  sticky(parts.join(' '), 0)
80
110
  end
81
111
 
112
+ #: (String file, (String | Integer)? bpm) -> void
82
113
  def analyze_skip(file, bpm)
83
114
  set_analyze_file_stickies(file, in_color("↷ #{bpm} BPM already set", :highlight))
84
115
  end
85
116
 
117
+ #: (String file, Integer? bpm) -> void
86
118
  def analyze_result(file, bpm)
87
119
  label = bpm ? in_color("#{bpm} BPM", :highlight) : in_color('No BPM detected', :highlight)
88
120
  set_analyze_file_stickies(file, label)
89
121
  end
90
122
 
123
+ #: (String message) -> bool
91
124
  def confirm(message)
92
125
  print in_color(message, :secondary)
93
126
  response = $stdin.gets.to_s.strip.downcase
94
127
  response == 'y'
95
128
  end
96
129
 
130
+ #: (String label, Array[String] options) -> String
97
131
  def select(label, options)
98
132
  @prompt.select(label, options, cycle: true)
99
133
  end
100
134
 
135
+ #: (String text, Symbol key) -> String
101
136
  def color(text, key)
102
137
  in_color(text, key)
103
138
  end
104
139
 
140
+ #: () -> void
105
141
  def clear
106
142
  print @cursor.clear_screen
107
143
  print @cursor.move_to(0, 0)
@@ -109,26 +145,41 @@ module Wavesync
109
145
 
110
146
  private
111
147
 
112
- def audio_info(sample_rate, bit_depth)
148
+ #: (AudioFormat format) -> String
149
+ def audio_info(format)
150
+ quality = format.file_type == 'mp3' ? format.bitrate&.to_s : format.bit_depth&.to_s
113
151
  [
114
- sample_rate_to_khz(sample_rate),
115
- bit_depth
152
+ sample_rate_to_khz(format.sample_rate),
153
+ quality
116
154
  ].compact.join('/')
117
155
  end
118
156
 
157
+ #: (AudioFormat format, Integer mp3_bitrate) -> String
158
+ def target_audio_info(format, mp3_bitrate)
159
+ quality = format.file_type == 'mp3' ? mp3_bitrate.to_s : format.bit_depth&.to_s
160
+ [
161
+ sample_rate_to_khz(format.sample_rate),
162
+ quality
163
+ ].compact.join('/')
164
+ end
165
+
166
+ #: (String string, Symbol key) -> String
119
167
  def in_color(string, key)
120
168
  Rainbow(string).color(THEME[key])
121
169
  end
122
170
 
171
+ #: (String text, Integer index) -> void
123
172
  def sticky(text, index)
124
173
  set_sticky(text, index)
125
174
  redraw
126
175
  end
127
176
 
177
+ #: (String text, Integer index) -> void
128
178
  def set_sticky(text, index)
129
179
  @sticky_lines[index] = text
130
180
  end
131
181
 
182
+ #: (String file, String label) -> void
132
183
  def set_analyze_file_stickies(file, label)
133
184
  path = Pathname.new(file)
134
185
  set_sticky(in_color(path.parent.basename.to_s, :secondary), 1)
@@ -137,13 +188,17 @@ module Wavesync
137
188
  redraw
138
189
  end
139
190
 
191
+ #: () -> void
140
192
  def redraw
141
193
  print @cursor.clear_screen
142
194
  print @cursor.move_to(0, 0)
143
195
  puts @sticky_lines.join("\n")
144
196
  end
145
197
 
198
+ #: (Integer? rate) -> String?
146
199
  def sample_rate_to_khz(rate)
200
+ return nil unless rate
201
+
147
202
  khz = rate.to_f / 1000
148
203
  (khz % 1).zero? ? khz.to_i.to_s : khz.round(1).to_s
149
204
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  module Wavesync
4
- VERSION = '1.0.0.alpha2'
5
+ VERSION = '1.0.0.alpha4'
5
6
  end
data/lib/wavesync.rb CHANGED
@@ -3,9 +3,12 @@
3
3
  module Wavesync
4
4
  end
5
5
 
6
+ require 'wavesync/logger'
6
7
  require 'wavesync/version'
7
8
  require 'wavesync/acid_chunk'
9
+ require 'wavesync/cue_chunk'
8
10
  require 'wavesync/audio_format'
11
+ require 'wavesync/ffmpeg'
9
12
  require 'wavesync/audio'
10
13
  require 'wavesync/track_padding'
11
14
  require 'wavesync/config'
@@ -13,10 +16,12 @@ require 'wavesync/device'
13
16
  require 'wavesync/ui'
14
17
  require 'wavesync/path_resolver'
15
18
  require 'wavesync/file_converter'
19
+ require 'wavesync/libmtp'
20
+ require 'wavesync/transport'
16
21
  require 'wavesync/scanner'
17
22
  require 'wavesync/bpm_detector'
18
23
  require 'wavesync/analyzer'
19
- require 'wavesync/set'
20
- require 'wavesync/set_editor'
24
+ require 'wavesync/setlist'
25
+ require 'wavesync/setlist_editor'
21
26
  require 'wavesync/commands'
22
27
  require 'wavesync/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wavesync
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.alpha2
4
+ version: 1.0.0.alpha4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Zecher
@@ -37,34 +37,6 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '3.1'
40
- - !ruby/object:Gem::Dependency
41
- name: streamio-ffmpeg
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '3.0'
47
- type: :runtime
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '3.0'
54
- - !ruby/object:Gem::Dependency
55
- name: taglib-ruby
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '2.0'
61
- type: :runtime
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '2.0'
68
40
  - !ruby/object:Gem::Dependency
69
41
  name: tty-cursor
70
42
  requirement: !ruby/object:Gem::Requirement
@@ -111,18 +83,31 @@ files:
111
83
  - lib/wavesync/cli.rb
112
84
  - lib/wavesync/commands.rb
113
85
  - lib/wavesync/commands/analyze.rb
86
+ - lib/wavesync/commands/clear_cache.rb
114
87
  - lib/wavesync/commands/command.rb
115
88
  - lib/wavesync/commands/help.rb
116
- - lib/wavesync/commands/set.rb
89
+ - lib/wavesync/commands/pull.rb
90
+ - lib/wavesync/commands/setlist.rb
117
91
  - lib/wavesync/commands/sync.rb
118
92
  - lib/wavesync/config.rb
93
+ - lib/wavesync/cue_chunk.rb
119
94
  - lib/wavesync/device.rb
95
+ - lib/wavesync/essentia_bpm_detector.rb
96
+ - lib/wavesync/ffmpeg.rb
97
+ - lib/wavesync/ffmpeg/probe.rb
120
98
  - lib/wavesync/file_converter.rb
99
+ - lib/wavesync/libmtp.rb
100
+ - lib/wavesync/logger.rb
121
101
  - lib/wavesync/path_resolver.rb
102
+ - lib/wavesync/percival_bpm_detector.rb
103
+ - lib/wavesync/python_venv.rb
122
104
  - lib/wavesync/scanner.rb
123
- - lib/wavesync/set.rb
124
- - lib/wavesync/set_editor.rb
105
+ - lib/wavesync/setlist.rb
106
+ - lib/wavesync/setlist_editor.rb
125
107
  - lib/wavesync/track_padding.rb
108
+ - lib/wavesync/transport.rb
109
+ - lib/wavesync/transport/filesystem.rb
110
+ - lib/wavesync/transport/mtp.rb
126
111
  - lib/wavesync/ui.rb
127
112
  - lib/wavesync/version.rb
128
113
  homepage: https://github.com/pixelate/wavesync
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'optparse'
4
-
5
- module Wavesync
6
- module Commands
7
- class Set < Command
8
- self.name = 'set'
9
- self.subcommands = [
10
- Subcommand.new(usage: 'set create NAME', description: 'Create a new track set'),
11
- Subcommand.new(usage: 'set edit NAME', description: 'Edit an existing track set'),
12
- Subcommand.new(usage: 'set list', description: 'List all track sets')
13
- ].freeze
14
-
15
- def run
16
- subcommand = ARGV.shift
17
-
18
- _options, config = parse_options(banner: 'Usage: wavesync set <subcommand> [options]')
19
-
20
- case subcommand
21
- when 'create'
22
- name = require_name('create')
23
- if Wavesync::Set.exists?(config.library, name)
24
- puts "Set '#{name}' already exists. Use 'wavesync set edit #{name}' to edit it."
25
- exit 1
26
- end
27
- set = Wavesync::Set.new(config.library, name)
28
- Wavesync::SetEditor.new(set, config.library).run
29
- when 'edit'
30
- name = require_name('edit')
31
- unless Wavesync::Set.exists?(config.library, name)
32
- puts "Set '#{name}' not found. Use 'wavesync set create #{name}' to create it."
33
- exit 1
34
- end
35
- set = Wavesync::Set.load(config.library, name)
36
- Wavesync::SetEditor.new(set, config.library).run
37
- when 'list'
38
- sets = Wavesync::Set.all(config.library)
39
- if sets.empty?
40
- puts 'No sets found.'
41
- else
42
- sets.each { |set| puts "#{set.name} (#{set.tracks.size} tracks)" }
43
- end
44
- else
45
- puts "Unknown subcommand: #{subcommand || '(none)'}"
46
- puts 'Available subcommands: create, edit, list'
47
- exit 1
48
- end
49
- end
50
-
51
- private
52
-
53
- def require_name(subcommand)
54
- name = ARGV.shift
55
- unless name
56
- puts "Usage: wavesync set #{subcommand} <name>"
57
- exit 1
58
- end
59
- name
60
- end
61
- end
62
- end
63
- end