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.
- checksums.yaml +4 -4
- data/README.md +95 -59
- data/config/devices.yml +20 -0
- data/lib/wavesync/acid_chunk.rb +45 -4
- data/lib/wavesync/analyzer.rb +17 -4
- data/lib/wavesync/audio.rb +153 -90
- data/lib/wavesync/audio_format.rb +9 -2
- data/lib/wavesync/bpm_detector.rb +14 -7
- data/lib/wavesync/cli.rb +3 -0
- data/lib/wavesync/commands/analyze.rb +2 -0
- data/lib/wavesync/commands/clear_cache.rb +75 -0
- data/lib/wavesync/commands/command.rb +4 -1
- data/lib/wavesync/commands/help.rb +6 -0
- data/lib/wavesync/commands/pull.rb +43 -0
- data/lib/wavesync/commands/setlist.rb +66 -0
- data/lib/wavesync/commands/sync.rb +19 -26
- data/lib/wavesync/commands.rb +52 -12
- data/lib/wavesync/config.rb +43 -3
- data/lib/wavesync/cue_chunk.rb +203 -0
- data/lib/wavesync/device.rb +32 -7
- data/lib/wavesync/essentia_bpm_detector.rb +38 -0
- data/lib/wavesync/ffmpeg/probe.rb +74 -0
- data/lib/wavesync/ffmpeg.rb +144 -0
- data/lib/wavesync/file_converter.rb +7 -2
- data/lib/wavesync/libmtp.rb +333 -0
- data/lib/wavesync/logger.rb +84 -0
- data/lib/wavesync/path_resolver.rb +32 -6
- data/lib/wavesync/percival_bpm_detector.rb +31 -0
- data/lib/wavesync/python_venv.rb +25 -0
- data/lib/wavesync/scanner.rb +143 -27
- data/lib/wavesync/{set.rb → setlist.rb} +28 -12
- data/lib/wavesync/setlist_editor.rb +556 -0
- data/lib/wavesync/track_padding.rb +15 -4
- data/lib/wavesync/transport/filesystem.rb +36 -0
- data/lib/wavesync/transport/mtp.rb +285 -0
- data/lib/wavesync/transport.rb +21 -0
- data/lib/wavesync/ui.rb +67 -12
- data/lib/wavesync/version.rb +2 -1
- data/lib/wavesync.rb +7 -2
- metadata +17 -32
- data/lib/wavesync/commands/set.rb +0 -63
- 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
|
-
|
|
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
|
|
45
|
-
target_info =
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/wavesync/version.rb
CHANGED
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/
|
|
20
|
-
require 'wavesync/
|
|
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.
|
|
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/
|
|
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/
|
|
124
|
-
- lib/wavesync/
|
|
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
|